diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f776cf09f..5d6c091bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,31 +222,79 @@ jobs: CLI_VERSION=$(node -p "require('./packages/cli/package.json').version") echo "cli=$CLI_VERSION" >> $GITHUB_OUTPUT - - name: Verify CLI published to npm + - name: Verify CLI and its walkerOS dependencies published to npm env: CLI_VERSION: ${{ steps.versions.outputs.cli }} run: | - # The image builds below run `npm install @walkeros/cli@$CLI_VERSION` - # in a fresh container: no local cache, no .npmrc. Ask the registry the - # same way, rather than through the runner's `npm view`, whose packument - # cache is primed by `changeset publish` moments earlier and can serve a - # pre-publish document for minutes (and whose setup-node .npmrc carries - # an unset _authToken under Trusted Publishing). The budget must clear - # the registry's ~5min packument max-age. - URL="https://registry.npmjs.org/@walkeros%2Fcli/${CLI_VERSION}" - for i in $(seq 1 30); do - CODE="$(curl -sS -o /tmp/npm-probe.json -w '%{http_code}' \ - -H 'Cache-Control: no-cache' "$URL" || echo 000)" - if [ "$CODE" = "200" ]; then - echo "Verified: @walkeros/cli@${CLI_VERSION} resolves on the registry" - exit 0 + # The image builds run `npm install @walkeros/cli@$CLI_VERSION` in a + # fresh container, which resolves the CLI's whole dependency closure. + # `changeset publish` lands those packages on the registry out of + # order, so wait for every @walkeros/* dependency too, not just the + # CLI. They all carry one version (the `fixed` group in + # .changeset/config.json). + # + # Wait for the tarball, not just the metadata. The packument lists a + # version minutes before its tarball is fetchable, and the tarball is + # what `npm install` downloads. Probe both, uncached, from this runner: + # buildkit shares its network, so a 200 here predicts a 200 there. + if ! PKGS="$(node -e ' + const m = require("./packages/cli/package.json"); + const deps = { ...m.dependencies, ...m.peerDependencies }; + const walkeros = Object.keys(deps).filter((n) => n.startsWith("@walkeros/")); + process.stdout.write(["@walkeros/cli", ...walkeros].join(" ")); + ')"; then + echo "::error::Could not read packages/cli/package.json to build the wait list" + exit 1 + fi + + # An empty list would pass this gate having verified nothing. + case " $PKGS " in + *" @walkeros/cli "*) ;; + *) + echo "::error::Wait list is missing @walkeros/cli (got: '$PKGS')" + exit 1 + ;; + esac + echo "Waiting for: $PKGS" + + for PKG in $PKGS; do + ENCODED="${PKG/\//%2F}" + URL="https://registry.npmjs.org/${ENCODED}" + OK=0 + for i in $(seq 1 30); do + CODE="$(curl -sS -o /tmp/npm-probe.json -w '%{http_code}' \ + -H 'Accept: application/vnd.npm.install-v1+json' "$URL" \ + || echo 000)" + TARBALL="" + if [ "$CODE" = "200" ]; then + TARBALL="$(node -e ' + const doc = JSON.parse( + require("fs").readFileSync("/tmp/npm-probe.json", "utf8"), + ); + process.stdout.write( + doc.versions?.[process.argv[1]]?.dist?.tarball ?? "", + ); + ' "$CLI_VERSION")" + fi + TCODE="-" + if [ -n "$TARBALL" ]; then + TCODE="$(curl -sS -o /dev/null -w '%{http_code}' -L "$TARBALL" \ + || echo 000)" + if [ "$TCODE" = "200" ]; then + echo "Verified: ${PKG}@${CLI_VERSION} (metadata + tarball)" + OK=1 + break + fi + fi + echo " ${PKG} attempt $i/30 (meta $CODE, tarball $TCODE): waiting..." + sleep 15 + done + if [ "$OK" != "1" ]; then + echo "::error::${PKG}@${CLI_VERSION} not installable after ~7min (meta $CODE, tarball $TCODE)" + exit 1 fi - echo "Attempt $i/30 (HTTP $CODE): waiting for npm propagation..." - sleep 15 done - echo "::error::@walkeros/cli@${CLI_VERSION} not resolvable after ~7min (last HTTP $CODE)" - head -c 2000 /tmp/npm-probe.json || true - exit 1 + echo "All packages the CLI install needs are on the registry." # Log in before the existence probes: anonymous `docker manifest inspect` # is rate limited, and a 429 is indistinguishable from "missing" below. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 83c76e544..2b976f2f7 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # walkeros +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] +- Updated dependencies [fd5949e] +- Updated dependencies [403ff6c] +- Updated dependencies [23e9034] + - @walkeros/cli@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 13e45029f..8d86ae023 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "walkeros", - "version": "4.5.0", + "version": "4.6.0", "description": "walkerOS CLI - Bundle and deploy walkerOS components", "license": "MIT", "type": "module", @@ -18,7 +18,7 @@ "clean": "rm -rf .turbo && rm -rf dist" }, "dependencies": { - "@walkeros/cli": "4.5.0" + "@walkeros/cli": "4.6.0" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/apps/demos/react/CHANGELOG.md b/apps/demos/react/CHANGELOG.md index 6a7fbee2b..04f2f02d1 100644 --- a/apps/demos/react/CHANGELOG.md +++ b/apps/demos/react/CHANGELOG.md @@ -1,5 +1,17 @@ # walkeros-demo-react +## 2.0.20 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/web-destination-api@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + - @walkeros/web-destination-gtag@4.6.0 + ## 2.0.19 ### Patch Changes diff --git a/apps/demos/react/package.json b/apps/demos/react/package.json index 8efe55b9a..3551dc0d9 100644 --- a/apps/demos/react/package.json +++ b/apps/demos/react/package.json @@ -1,6 +1,6 @@ { "name": "walkeros-demo-react", - "version": "2.0.19", + "version": "2.0.20", "private": true, "type": "module", "scripts": { @@ -16,12 +16,12 @@ }, "dependencies": { "@remix-run/router": "^1.23.0", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-destination-api": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-destination-api": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.10.1" @@ -34,7 +34,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", + "@walkeros/config": "4.6.0", "autoprefixer": "^10.4.23", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/apps/demos/storybook/CHANGELOG.md b/apps/demos/storybook/CHANGELOG.md index dcf831429..54f095af0 100644 --- a/apps/demos/storybook/CHANGELOG.md +++ b/apps/demos/storybook/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/storybook-demo +## 4.6.0 + +### Patch Changes + +- @walkeros/web-source-browser@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/demos/storybook/package.json b/apps/demos/storybook/package.json index c9640a52f..4e29d6a8f 100644 --- a/apps/demos/storybook/package.json +++ b/apps/demos/storybook/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/storybook-demo", "private": true, - "version": "4.5.0", + "version": "4.6.0", "type": "module", "scripts": { "dev": "vite", @@ -13,8 +13,8 @@ "build-storybook": "storybook build" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -26,7 +26,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/storybook-addon": "4.5.0", + "@walkeros/storybook-addon": "4.6.0", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/explorer/CHANGELOG.md b/apps/explorer/CHANGELOG.md index 668277c0b..677bf7427 100644 --- a/apps/explorer/CHANGELOG.md +++ b/apps/explorer/CHANGELOG.md @@ -1,5 +1,15 @@ # @walkeros/explorer +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 6d9d3bf73..5d6bfc730 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/explorer", - "version": "4.5.0", + "version": "4.6.0", "description": "Interactive React components for walkerOS documentation and exploration", "license": "MIT", "type": "module", @@ -36,10 +36,10 @@ "@rjsf/core": "^6.1.2", "@rjsf/utils": "^6.1.2", "@rjsf/validator-ajv8": "^6.1.2", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "clsx": "^2.1.1", "monaco-editor": "^0.55.1", "prettier": "^3.7.4", @@ -65,8 +65,8 @@ "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", + "@walkeros/config": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", "eslint": "^9.23.0", "eslint-plugin-jest": "^29.15.2", "eslint-plugin-storybook": "^10.1.11", diff --git a/apps/quickstart/CHANGELOG.md b/apps/quickstart/CHANGELOG.md index 08a62fa73..61d0cbee8 100644 --- a/apps/quickstart/CHANGELOG.md +++ b/apps/quickstart/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/quickstart +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/web-destination-api@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + - @walkeros/web-destination-gtag@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/quickstart/package.json b/apps/quickstart/package.json index 05c1f366c..594e4016e 100644 --- a/apps/quickstart/package.json +++ b/apps/quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/quickstart", - "version": "4.5.0", + "version": "4.6.0", "private": true, "description": "Verified code examples for walkerOS documentation", "license": "MIT", @@ -14,12 +14,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/collector": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", - "@walkeros/web-destination-api": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/collector": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", + "@walkeros/web-destination-api": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/apps/storybook-addon/CHANGELOG.md b/apps/storybook-addon/CHANGELOG.md index 9a0116960..dfc6e2bd3 100644 --- a/apps/storybook-addon/CHANGELOG.md +++ b/apps/storybook-addon/CHANGELOG.md @@ -1,5 +1,15 @@ # @walkeros/storybook-addon +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/storybook-addon/package.json b/apps/storybook-addon/package.json index c608c2d75..46e7bfdbd 100644 --- a/apps/storybook-addon/package.json +++ b/apps/storybook-addon/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/storybook-addon", - "version": "4.5.0", + "version": "4.6.0", "description": "Visualize, debug, and validate walkerOS event tracking in your Storybook stories. Real-time event capture with visual DOM highlighting for data-attribute based tagging.", "keywords": [ "storybook-addons", @@ -62,10 +62,10 @@ }, "dependencies": { "@storybook/icons": "^2.0.1", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0" }, "devDependencies": { "@storybook/addon-docs": "^10.1.9", @@ -74,7 +74,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", + "@walkeros/config": "4.6.0", "auto": "^11.3.6", "boxen": "^8.0.1", "npm-run-all2": "^8.0.4", diff --git a/apps/walkerjs/CHANGELOG.md b/apps/walkerjs/CHANGELOG.md index e43442724..f8aaf8e71 100644 --- a/apps/walkerjs/CHANGELOG.md +++ b/apps/walkerjs/CHANGELOG.md @@ -1,5 +1,18 @@ # @walkeros/walker.js +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] +- Updated dependencies [403ff6c] + - @walkeros/collector@4.6.0 + - @walkeros/web-source-datalayer@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/web-source-session@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/apps/walkerjs/package.json b/apps/walkerjs/package.json index e3ba9ced2..2da549d13 100644 --- a/apps/walkerjs/package.json +++ b/apps/walkerjs/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/walker.js", - "version": "4.5.0", + "version": "4.6.0", "description": "Ready-to-use walkerOS bundle with browser source, collector, and dataLayer support", "license": "MIT", "main": "./dist/index.js", @@ -40,12 +40,12 @@ "preview": "npm run build && npx serve -l 3333 examples" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/collector": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", - "@walkeros/web-source-datalayer": "4.5.0", - "@walkeros/web-source-session": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/collector": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", + "@walkeros/web-source-datalayer": "4.6.0", + "@walkeros/web-source-session": "4.6.0" }, "devDependencies": { "@swc/jest": "^0.2.39", diff --git a/context7.json b/context7.json index dc3f60448..5f5edda9c 100644 --- a/context7.json +++ b/context7.json @@ -1,4 +1,40 @@ { + "$schema": "https://context7.com/schema/context7.json", "url": "https://context7.com/elbwalker/walkeros", - "public_key": "pk_i1Ela9ZtaZmC1cYI8QQpR" + "public_key": "pk_i1Ela9ZtaZmC1cYI8QQpR", + "projectTitle": "walkerOS", + "description": "Open-source, observable tag management and event data collection: compose Source -> Collector -> Destination pipelines as JSON flow configs, then see, diff, and prove every event from source to destination. CLI, MCP servers, and skills for coding agents.", + "folders": ["website/docs", "skills", "packages"], + "excludeFolders": [ + "node_modules", + "dist", + "build", + "coverage", + "**/__tests__", + "**/__mocks__", + "**/examples", + "website/build", + "website/skills-generated", + "website/src", + "website/static", + "apps" + ], + "excludeFiles": [ + "package-lock.json", + "CHANGELOG.md", + "tsup.config.ts", + "jest.config.mjs", + "jest.config.js" + ], + "rules": [ + "Packages are `@walkeros/*`, the CLI binary is `walkeros`. walker.js-era package names are historical; never suggest them.", + "walkerOS collects events and routes them. It is not a product analytics tool, a consent management platform, or a BI layer.", + "Name events as entity-action pairs, entity first, two words: `product add`, `page view`, `order complete`.", + "In the browser, tag HTML with `data-elb`, `data-elbaction`, `data-elb-`, `data-elbcontext`, `data-elbglobals`. Never build tracking on CSS selectors.", + "Migrating from GTM: keep the existing dataLayer via the dataLayer source and add `data-elb` tagging incrementally.", + "Keep events vendor-neutral. Vendor-specific shapes belong in each destination's `mapping`, never in the tagging.", + "Consent is configuration: set `consent` requirements on destinations so events reach a vendor only with granted states.", + "Never write a flow config from memory. With the walkerOS MCP use `package_search`, `package_get`, then `flow_validate`; otherwise read the package docs first.", + "Prove before deploying: `walkeros push flow.json --event '{\"name\":\"product add\"}' --simulate destination.NAME` shows what a destination would send." + ] } diff --git a/package-lock.json b/package-lock.json index 1e84accd1..2ad20d206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,10 +49,10 @@ }, "apps/cli": { "name": "walkeros", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/cli": "4.5.0" + "@walkeros/cli": "4.6.0" }, "bin": { "walkeros": "dist/index.js" @@ -64,15 +64,15 @@ }, "apps/demos/react": { "name": "walkeros-demo-react", - "version": "2.0.19", + "version": "2.0.20", "dependencies": { "@remix-run/router": "^1.23.0", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-destination-api": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-destination-api": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.10.1" @@ -85,7 +85,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", + "@walkeros/config": "4.6.0", "autoprefixer": "^10.4.23", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -147,10 +147,10 @@ }, "apps/demos/storybook": { "name": "@walkeros/storybook-demo", - "version": "4.5.0", + "version": "4.6.0", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -163,7 +163,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/storybook-addon": "4.5.0", + "@walkeros/storybook-addon": "4.6.0", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -513,7 +513,7 @@ }, "apps/explorer": { "name": "@walkeros/explorer", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -527,10 +527,10 @@ "@rjsf/core": "^6.1.2", "@rjsf/utils": "^6.1.2", "@rjsf/validator-ajv8": "^6.1.2", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "clsx": "^2.1.1", "monaco-editor": "^0.55.1", "prettier": "^3.7.4", @@ -552,8 +552,8 @@ "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", + "@walkeros/config": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", "eslint": "^9.23.0", "eslint-plugin-jest": "^29.15.2", "eslint-plugin-storybook": "^10.1.11", @@ -884,27 +884,27 @@ }, "apps/quickstart": { "name": "@walkeros/quickstart", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-destination-api": "4.5.0", - "@walkeros/web-destination-gtag": "4.5.0", - "@walkeros/web-source-browser": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-destination-api": "4.6.0", + "@walkeros/web-destination-gtag": "4.6.0", + "@walkeros/web-source-browser": "4.6.0" } }, "apps/storybook-addon": { "name": "@walkeros/storybook-addon", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@storybook/icons": "^2.0.1", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0" }, "devDependencies": { "@storybook/addon-docs": "^10.1.9", @@ -913,7 +913,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.5.0", + "@walkeros/config": "4.6.0", "auto": "^11.3.6", "boxen": "^8.0.1", "npm-run-all2": "^8.0.4", @@ -937,15 +937,15 @@ }, "apps/walkerjs": { "name": "@walkeros/walker.js", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", - "@walkeros/web-source-datalayer": "4.5.0", - "@walkeros/web-source-session": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", + "@walkeros/web-source-datalayer": "4.6.0", + "@walkeros/web-source-session": "4.6.0" }, "devDependencies": { "@swc/jest": "^0.2.39", @@ -47738,15 +47738,15 @@ }, "packages/cli": { "name": "@walkeros/cli", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@vercel/nft": "^1.10.2", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", - "@walkeros/server-destination-api": "4.5.0", - "@walkeros/transformer-validate": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", + "@walkeros/server-destination-api": "4.6.0", + "@walkeros/transformer-validate": "4.6.0", "ajv": "^8.17.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", @@ -47776,8 +47776,8 @@ "@types/pacote": "^11.1.8", "@types/picomatch": "4.0.3", "@types/semver": "^7.7.1", - "@walkeros/config": "4.5.0", - "@walkeros/core": "4.5.0", + "@walkeros/config": "4.6.0", + "@walkeros/core": "4.6.0", "msw": "^2.12.10", "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" @@ -48970,7 +48970,7 @@ }, "packages/collector": { "name": "@walkeros/collector", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -48979,15 +48979,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/config": { "name": "@walkeros/config", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49332,7 +49332,7 @@ }, "packages/core": { "name": "@walkeros/core", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49346,27 +49346,27 @@ }, "packages/destinations/demo": { "name": "@walkeros/destination-demo", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/mcps/mcp": { "name": "@walkeros/mcp", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/cli": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/cli": "4.6.0", + "@walkeros/core": "4.6.0" }, "bin": { "walkeros-mcp": "dist/stdio.js" }, "devDependencies": { "@types/node": "^25.9.1", - "@walkeros/config": "4.5.0" + "@walkeros/config": "4.6.0" }, "engines": { "node": ">=20.0.0" @@ -49377,12 +49377,12 @@ }, "packages/mcps/source-browser": { "name": "@walkeros/mcp-source-browser", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "jsdom": "^29.1.1" }, "bin": { @@ -49391,9 +49391,9 @@ "devDependencies": { "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", - "@walkeros/collector": "4.5.0", - "@walkeros/config": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/config": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "engines": { "node": ">=18.0.0" @@ -49784,7 +49784,7 @@ }, "packages/server/core": { "name": "@walkeros/server-core", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49793,16 +49793,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" } }, "packages/server/destinations/amplitude": { "name": "@walkeros/server-destination-amplitude", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49812,16 +49812,16 @@ "license": "MIT", "dependencies": { "@amplitude/analytics-node": "^1.5.53", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/api": { "name": "@walkeros/server-destination-api", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49830,14 +49830,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {} }, "packages/server/destinations/aws": { "name": "@walkeros/server-destination-aws", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49849,14 +49849,14 @@ "@aws-sdk/client-firehose": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sts": "^3.952.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {} }, "packages/server/destinations/bing": { "name": "@walkeros/server-destination-bing", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49865,16 +49865,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/criteo": { "name": "@walkeros/server-destination-criteo", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49883,16 +49883,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/customerio": { "name": "@walkeros/server-destination-customerio", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49901,17 +49901,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "customerio-node": "^4.2.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/datamanager": { "name": "@walkeros/server-destination-datamanager", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49920,17 +49920,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "google-auth-library": "^10.5.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/file": { "name": "@walkeros/server-destination-file", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49939,16 +49939,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/gcp": { "name": "@walkeros/server-destination-gcp", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49960,14 +49960,14 @@ "@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery-storage": "^5.1.0", "@google-cloud/pubsub": "^5.3.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {} }, "packages/server/destinations/hubspot": { "name": "@walkeros/server-destination-hubspot", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49977,16 +49977,16 @@ "license": "MIT", "dependencies": { "@hubspot/api-client": "^13.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/kafka": { "name": "@walkeros/server-destination-kafka", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -49995,17 +49995,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "kafkajs": "^2.2.4" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/klaviyo": { "name": "@walkeros/server-destination-klaviyo", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50014,17 +50014,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "klaviyo-api": "^22.0.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/linkedin": { "name": "@walkeros/server-destination-linkedin", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50033,16 +50033,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/meta": { "name": "@walkeros/server-destination-meta", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50051,16 +50051,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/mixpanel": { "name": "@walkeros/server-destination-mixpanel", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50069,17 +50069,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "mixpanel": "^0.22.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/mparticle": { "name": "@walkeros/server-destination-mparticle", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50088,16 +50088,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/pinterest": { "name": "@walkeros/server-destination-pinterest", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50106,16 +50106,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/posthog": { "name": "@walkeros/server-destination-posthog", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50124,17 +50124,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "posthog-node": "^5.0.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/reddit": { "name": "@walkeros/server-destination-reddit", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50143,16 +50143,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/redis": { "name": "@walkeros/server-destination-redis", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50161,17 +50161,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "ioredis": "^5.10.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/rudderstack": { "name": "@walkeros/server-destination-rudderstack", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50181,16 +50181,16 @@ "license": "MIT", "dependencies": { "@rudderstack/rudder-sdk-node": "^3.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/segment": { "name": "@walkeros/server-destination-segment", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50200,16 +50200,16 @@ "license": "MIT", "dependencies": { "@segment/analytics-node": "^3.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/slack": { "name": "@walkeros/server-destination-slack", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50219,16 +50219,16 @@ "license": "MIT", "dependencies": { "@slack/web-api": "^7.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/snapchat": { "name": "@walkeros/server-destination-snapchat", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50237,16 +50237,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/sqlite": { "name": "@walkeros/server-destination-sqlite", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50255,13 +50255,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { "@libsql/client": "^0.17.0", "@types/better-sqlite3": "^7.6.13", - "@walkeros/collector": "4.5.0", + "@walkeros/collector": "4.6.0", "better-sqlite3": "^12.0.0" }, "peerDependencies": { @@ -50279,7 +50279,7 @@ }, "packages/server/destinations/tiktok": { "name": "@walkeros/server-destination-tiktok", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50288,16 +50288,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/destinations/twitter": { "name": "@walkeros/server-destination-twitter", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50306,17 +50306,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "oauth-1.0a": "^2.2.6" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/server/sources/aws": { "name": "@walkeros/server-source-aws", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50327,11 +50327,11 @@ "dependencies": { "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sqs": "^3.952.0", - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.159", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "peerDependencies": { "@types/aws-lambda": "^8.10.0" @@ -50339,7 +50339,7 @@ }, "packages/server/sources/express": { "name": "@walkeros/server-source-express", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50348,8 +50348,8 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", "cors": "^2.8.5", "express": "^5.2.1" }, @@ -50661,17 +50661,17 @@ }, "packages/server/sources/fetch": { "name": "@walkeros/server-source-fetch", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": {} }, "packages/server/sources/gcp": { "name": "@walkeros/server-source-gcp", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50681,8 +50681,8 @@ "license": "MIT", "dependencies": { "@google-cloud/pubsub": "^5.3.0", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": {}, "peerDependencies": { @@ -50691,7 +50691,7 @@ }, "packages/server/stores/fs": { "name": "@walkeros/server-store-fs", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50700,15 +50700,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/server/stores/gcs": { "name": "@walkeros/server-store-gcs", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50717,13 +50717,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": {} }, "packages/server/stores/s3": { "name": "@walkeros/server-store-s3", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50732,14 +50732,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "s3mini": "^0.9.1" }, "devDependencies": {} }, "packages/server/stores/sheets": { "name": "@walkeros/server-store-sheets", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50748,13 +50748,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": {} }, "packages/server/transformers/bot": { "name": "@walkeros/server-transformer-bot", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50763,13 +50763,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "isbot": "^5.1.39" } }, "packages/server/transformers/file": { "name": "@walkeros/server-transformer-file", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50778,15 +50778,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/server/transformers/fingerprint": { "name": "@walkeros/server-transformer-fingerprint", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50795,12 +50795,12 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" } }, "packages/server/transformers/validate": { @@ -50821,38 +50821,38 @@ }, "packages/transformers/demo": { "name": "@walkeros/transformer-demo", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/transformers/ga4": { "name": "@walkeros/transformer-ga4", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/transformers/validate": { "name": "@walkeros/transformer-validate", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/web/core": { "name": "@walkeros/web-core", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50861,15 +50861,15 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } }, "packages/web/destinations/amplitude": { "name": "@walkeros/web-destination-amplitude", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50879,16 +50879,16 @@ "license": "MIT", "dependencies": { "@amplitude/unified": "^1.0.16", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/api": { "name": "@walkeros/web-destination-api", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50897,16 +50897,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/clarity": { "name": "@walkeros/web-destination-clarity", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50916,16 +50916,16 @@ "license": "MIT", "dependencies": { "@microsoft/clarity": "^1.0.2", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/d8a": { "name": "@walkeros/web-destination-d8a", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50935,16 +50935,16 @@ "license": "MIT", "dependencies": { "@d8a-tech/wt": "^1.2.1", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/fullstory": { "name": "@walkeros/web-destination-fullstory", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50954,16 +50954,16 @@ "license": "MIT", "dependencies": { "@fullstory/browser": "^2.0.8", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/gtag": { "name": "@walkeros/web-destination-gtag", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50972,13 +50972,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" } }, "packages/web/destinations/heap": { "name": "@walkeros/web-destination-heap", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -50987,16 +50987,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/hotjar": { "name": "@walkeros/web-destination-hotjar", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51006,16 +51006,16 @@ "license": "MIT", "dependencies": { "@hotjar/browser": "^1.0.9", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/linkedin": { "name": "@walkeros/web-destination-linkedin", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51024,16 +51024,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/matomo": { "name": "@walkeros/web-destination-matomo", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51042,16 +51042,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/meta": { "name": "@walkeros/web-destination-meta", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51060,17 +51060,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { "@types/facebook-pixel": "^0.0.31", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/mixpanel": { "name": "@walkeros/web-destination-mixpanel", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51079,18 +51079,18 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", "mixpanel-browser": "^2.78.0" }, "devDependencies": { "@types/mixpanel-browser": "^2.50.0", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/optimizely": { "name": "@walkeros/web-destination-optimizely", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51100,16 +51100,16 @@ "license": "MIT", "dependencies": { "@optimizely/optimizely-sdk": "^6.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/piano": { "name": "@walkeros/web-destination-piano", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51118,16 +51118,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/pinterest": { "name": "@walkeros/web-destination-pinterest", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51136,16 +51136,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/piwikpro": { "name": "@walkeros/web-destination-piwikpro", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51154,16 +51154,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/plausible": { "name": "@walkeros/web-destination-plausible", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51172,16 +51172,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/posthog": { "name": "@walkeros/web-destination-posthog", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51190,17 +51190,17 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0", "posthog-js": "^1.367.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/segment": { "name": "@walkeros/web-destination-segment", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51210,16 +51210,16 @@ "license": "MIT", "dependencies": { "@segment/analytics-next": "^1.82.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/destinations/snowplow": { "name": "@walkeros/web-destination-snowplow", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51228,19 +51228,19 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { "@snowplow/browser-plugin-snowplow-ecommerce": "^4.6.8", "@snowplow/browser-tracker-core": "^4.6.8", - "@walkeros/collector": "4.5.0", - "@walkeros/config": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/config": "4.6.0" } }, "packages/web/destinations/tiktok": { "name": "@walkeros/web-destination-tiktok", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51249,16 +51249,16 @@ ], "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/sources/browser": { "name": "@walkeros/web-source-browser", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51267,14 +51267,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" } }, "packages/web/sources/cmps/cookiefirst": { "name": "@walkeros/web-source-cmp-cookiefirst", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51283,14 +51283,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": {} }, "packages/web/sources/cmps/cookiepro": { "name": "@walkeros/web-source-cmp-cookiepro", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51299,14 +51299,14 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": {} }, "packages/web/sources/cmps/usercentrics": { "name": "@walkeros/web-source-cmp-usercentrics", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51315,13 +51315,13 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" } }, "packages/web/sources/dataLayer": { "name": "@walkeros/web-source-datalayer", - "version": "4.5.0", + "version": "4.6.0", "funding": [ { "type": "GitHub Sponsors", @@ -51330,8 +51330,8 @@ ], "license": "MIT", "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": { "@types/gtag.js": "^0.0.20" @@ -51339,107 +51339,107 @@ }, "packages/web/sources/demo": { "name": "@walkeros/source-demo", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "packages/web/sources/session": { "name": "@walkeros/web-source-session", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" } }, "website": { "name": "@walkeros/website", - "version": "4.5.0", + "version": "4.6.0", "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", "@docusaurus/theme-live-codeblock": "^3.9.2", "@docusaurus/theme-mermaid": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", - "@walkeros/collector": "^4.5.0", - "@walkeros/core": "^4.5.0", - "@walkeros/explorer": "^4.5.0", - "@walkeros/server-destination-amplitude": "^4.5.0", - "@walkeros/server-destination-api": "^4.5.0", - "@walkeros/server-destination-aws": "^4.5.0", - "@walkeros/server-destination-bing": "^4.5.0", - "@walkeros/server-destination-criteo": "^4.5.0", - "@walkeros/server-destination-customerio": "^4.5.0", - "@walkeros/server-destination-datamanager": "^4.5.0", - "@walkeros/server-destination-file": "^4.5.0", - "@walkeros/server-destination-gcp": "^4.5.0", - "@walkeros/server-destination-hubspot": "^4.5.0", - "@walkeros/server-destination-kafka": "^4.5.0", - "@walkeros/server-destination-klaviyo": "^4.5.0", - "@walkeros/server-destination-linkedin": "^4.5.0", - "@walkeros/server-destination-meta": "^4.5.0", - "@walkeros/server-destination-mixpanel": "^4.5.0", - "@walkeros/server-destination-mparticle": "^4.5.0", - "@walkeros/server-destination-pinterest": "^4.5.0", - "@walkeros/server-destination-posthog": "^4.5.0", - "@walkeros/server-destination-reddit": "^4.5.0", - "@walkeros/server-destination-redis": "^4.5.0", - "@walkeros/server-destination-rudderstack": "^4.5.0", - "@walkeros/server-destination-segment": "^4.5.0", - "@walkeros/server-destination-slack": "^4.5.0", - "@walkeros/server-destination-snapchat": "^4.5.0", - "@walkeros/server-destination-sqlite": "^4.5.0", - "@walkeros/server-destination-tiktok": "^4.5.0", - "@walkeros/server-destination-twitter": "^4.5.0", - "@walkeros/server-source-aws": "^4.5.0", - "@walkeros/server-source-express": "^4.5.0", - "@walkeros/server-source-fetch": "^4.5.0", - "@walkeros/server-source-gcp": "^4.5.0", - "@walkeros/server-store-fs": "^4.5.0", - "@walkeros/server-store-gcs": "^4.5.0", - "@walkeros/server-store-s3": "^4.5.0", - "@walkeros/server-store-sheets": "^4.5.0", - "@walkeros/server-transformer-bot": "^4.5.0", - "@walkeros/server-transformer-file": "^4.5.0", - "@walkeros/server-transformer-fingerprint": "^4.5.0", - "@walkeros/transformer-ga4": "^4.5.0", - "@walkeros/transformer-validate": "^4.5.0", - "@walkeros/walker.js": "^4.5.0", - "@walkeros/web-destination-amplitude": "^4.5.0", - "@walkeros/web-destination-api": "^4.5.0", - "@walkeros/web-destination-clarity": "^4.5.0", - "@walkeros/web-destination-d8a": "^4.5.0", - "@walkeros/web-destination-fullstory": "^4.5.0", - "@walkeros/web-destination-gtag": "^4.5.0", - "@walkeros/web-destination-heap": "^4.5.0", - "@walkeros/web-destination-hotjar": "^4.5.0", - "@walkeros/web-destination-linkedin": "^4.5.0", - "@walkeros/web-destination-matomo": "^4.5.0", - "@walkeros/web-destination-meta": "^4.5.0", - "@walkeros/web-destination-mixpanel": "^4.5.0", - "@walkeros/web-destination-optimizely": "^4.5.0", - "@walkeros/web-destination-piano": "^4.5.0", - "@walkeros/web-destination-pinterest": "^4.5.0", - "@walkeros/web-destination-piwikpro": "^4.5.0", - "@walkeros/web-destination-plausible": "^4.5.0", - "@walkeros/web-destination-posthog": "^4.5.0", - "@walkeros/web-destination-segment": "^4.5.0", - "@walkeros/web-destination-snowplow": "^4.5.0", - "@walkeros/web-destination-tiktok": "^4.5.0", - "@walkeros/web-source-browser": "^4.5.0", - "@walkeros/web-source-cmp-cookiefirst": "^4.5.0", - "@walkeros/web-source-cmp-cookiepro": "^4.5.0", - "@walkeros/web-source-cmp-usercentrics": "^4.5.0", - "@walkeros/web-source-datalayer": "^4.5.0", - "@walkeros/web-source-session": "^4.5.0", + "@walkeros/collector": "^4.6.0", + "@walkeros/core": "^4.6.0", + "@walkeros/explorer": "^4.6.0", + "@walkeros/server-destination-amplitude": "^4.6.0", + "@walkeros/server-destination-api": "^4.6.0", + "@walkeros/server-destination-aws": "^4.6.0", + "@walkeros/server-destination-bing": "^4.6.0", + "@walkeros/server-destination-criteo": "^4.6.0", + "@walkeros/server-destination-customerio": "^4.6.0", + "@walkeros/server-destination-datamanager": "^4.6.0", + "@walkeros/server-destination-file": "^4.6.0", + "@walkeros/server-destination-gcp": "^4.6.0", + "@walkeros/server-destination-hubspot": "^4.6.0", + "@walkeros/server-destination-kafka": "^4.6.0", + "@walkeros/server-destination-klaviyo": "^4.6.0", + "@walkeros/server-destination-linkedin": "^4.6.0", + "@walkeros/server-destination-meta": "^4.6.0", + "@walkeros/server-destination-mixpanel": "^4.6.0", + "@walkeros/server-destination-mparticle": "^4.6.0", + "@walkeros/server-destination-pinterest": "^4.6.0", + "@walkeros/server-destination-posthog": "^4.6.0", + "@walkeros/server-destination-reddit": "^4.6.0", + "@walkeros/server-destination-redis": "^4.6.0", + "@walkeros/server-destination-rudderstack": "^4.6.0", + "@walkeros/server-destination-segment": "^4.6.0", + "@walkeros/server-destination-slack": "^4.6.0", + "@walkeros/server-destination-snapchat": "^4.6.0", + "@walkeros/server-destination-sqlite": "^4.6.0", + "@walkeros/server-destination-tiktok": "^4.6.0", + "@walkeros/server-destination-twitter": "^4.6.0", + "@walkeros/server-source-aws": "^4.6.0", + "@walkeros/server-source-express": "^4.6.0", + "@walkeros/server-source-fetch": "^4.6.0", + "@walkeros/server-source-gcp": "^4.6.0", + "@walkeros/server-store-fs": "^4.6.0", + "@walkeros/server-store-gcs": "^4.6.0", + "@walkeros/server-store-s3": "^4.6.0", + "@walkeros/server-store-sheets": "^4.6.0", + "@walkeros/server-transformer-bot": "^4.6.0", + "@walkeros/server-transformer-file": "^4.6.0", + "@walkeros/server-transformer-fingerprint": "^4.6.0", + "@walkeros/transformer-ga4": "^4.6.0", + "@walkeros/transformer-validate": "^4.6.0", + "@walkeros/walker.js": "^4.6.0", + "@walkeros/web-destination-amplitude": "^4.6.0", + "@walkeros/web-destination-api": "^4.6.0", + "@walkeros/web-destination-clarity": "^4.6.0", + "@walkeros/web-destination-d8a": "^4.6.0", + "@walkeros/web-destination-fullstory": "^4.6.0", + "@walkeros/web-destination-gtag": "^4.6.0", + "@walkeros/web-destination-heap": "^4.6.0", + "@walkeros/web-destination-hotjar": "^4.6.0", + "@walkeros/web-destination-linkedin": "^4.6.0", + "@walkeros/web-destination-matomo": "^4.6.0", + "@walkeros/web-destination-meta": "^4.6.0", + "@walkeros/web-destination-mixpanel": "^4.6.0", + "@walkeros/web-destination-optimizely": "^4.6.0", + "@walkeros/web-destination-piano": "^4.6.0", + "@walkeros/web-destination-pinterest": "^4.6.0", + "@walkeros/web-destination-piwikpro": "^4.6.0", + "@walkeros/web-destination-plausible": "^4.6.0", + "@walkeros/web-destination-posthog": "^4.6.0", + "@walkeros/web-destination-segment": "^4.6.0", + "@walkeros/web-destination-snowplow": "^4.6.0", + "@walkeros/web-destination-tiktok": "^4.6.0", + "@walkeros/web-source-browser": "^4.6.0", + "@walkeros/web-source-cmp-cookiefirst": "^4.6.0", + "@walkeros/web-source-cmp-cookiepro": "^4.6.0", + "@walkeros/web-source-cmp-usercentrics": "^4.6.0", + "@walkeros/web-source-datalayer": "^4.6.0", + "@walkeros/web-source-session": "^4.6.0", "css-loader": "^7.1.2", "prism-react-renderer": "^2.4.1", "react": "^19.2.4", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 393bbfae6..98d253657 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,50 @@ # @walkeros/cli +## 4.6.0 + +### Minor Changes + +- 23e9034: `walkeros auth login` now uses the standard device authorization + grant and refreshes its session automatically. Existing tokens keep working + until they expire; run `walkeros auth login` once to switch. + + Breaking: `getAuthHeaders` is async, and it rejects when the session needs a + refresh that cannot be carried out rather than quietly returning no header. + `createApiClient` no longer throws when unauthenticated, the request it makes + does. Removed exports: `getToken`, `requestDeviceCode`, `pollForToken`, and + the `DeviceCodeResult`, `DeviceCodeOptions`, `PollOptions` and `PollResult` + types; `startDeviceAuthorization` and `completeDeviceLogin` replace the last + two. + +### Patch Changes + +- 8802281: The BigQuery destination no longer applies `config.timeout` as a + deadline on the Storage Write API append stream, which killed healthy + connections roughly every ten seconds and caused reconnect churn, latency + spikes, and intermittent 5xx responses. Error logs now show the error's + message, name and status code in CLI output, and no longer include event + payloads. +- fd5949e: `fetchHealth` and `compareContract` accept an optional base URL, so a + caller that is not the local CLI can probe its own backend instead of the one + resolved from `WALKEROS_APP_URL` and the CLI config file. Omitting it keeps + today's resolution. + + `diagnostics` passes the app URL it reports, so the contract verdict and + `appUrl.resolved` always describe the same backend. A hosted MCP no longer + probes production while naming its own deployment. + +- 403ff6c: The MCP server carries `hub_manage`, which reads a flow's release + history, its rationale and the threads on it, and a read-only `frame_manage`, + which reads the frames of a measurement plan. The CLI gains the matching + programmatic calls. `ToolClient` gains eleven required methods, so a custom + implementation of that interface must add them. +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/server-core@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/server-destination-api@4.6.0 + - @walkeros/transformer-validate@4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/cli/examples/docker-compose.runner.yml b/packages/cli/examples/docker-compose.runner.yml index 49cad2991..70abc359e 100644 --- a/packages/cli/examples/docker-compose.runner.yml +++ b/packages/cli/examples/docker-compose.runner.yml @@ -19,6 +19,8 @@ services: - ./flow.json:/app/flow.json environment: - BUNDLE=/app/flow.json + # An automation token (wos_pat_...) from Account, Automation tokens, or a + # bound runner token (wos_run_...) from a flow's self-hosted deploy tab. - WALKEROS_TOKEN=${WALKEROS_TOKEN} - PROJECT_ID=${PROJECT_ID} - PORT=8080 diff --git a/packages/cli/openapi/spec.json b/packages/cli/openapi/spec.json index 51e5bb48e..a5e3feb7f 100644 --- a/packages/cli/openapi/spec.json +++ b/packages/cli/openapi/spec.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "walkerOS Tag Manager API", - "version": "4.3.0", + "version": "4.6.1", "description": "API for managing walkerOS flows, projects, and real-time event observation.", "contact": { "name": "elbwalker", @@ -20,6 +20,10 @@ "name": "Auth", "description": "Authentication (magic link, session, identity)" }, + { + "name": "OAuth", + "description": "OAuth 2.1 authorization server (client registration, admin clients)" + }, { "name": "Projects", "description": "Project management and membership" @@ -599,7 +603,7 @@ "required": ["projectId", "projectName", "role", "joinedAt"] } }, - "apiTokens": { + "tokens": { "type": "array", "items": { "type": "object", @@ -610,12 +614,21 @@ "name": { "type": "string" }, + "kind": { + "type": "string", + "example": "automation" + }, + "scope": { + "type": "string", + "example": "read write" + }, + "audience": { + "type": "string", + "example": "api mcp" + }, "projectId": { "type": ["string", "null"] }, - "origin": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -623,7 +636,7 @@ "type": ["string", "null"] }, "expiresAt": { - "type": ["string", "null"] + "type": "string" }, "revokedAt": { "type": ["string", "null"] @@ -632,8 +645,10 @@ "required": [ "id", "name", + "kind", + "scope", + "audience", "projectId", - "origin", "createdAt", "lastUsedAt", "expiresAt", @@ -662,40 +677,6 @@ "required": ["id", "createdAt", "expiresAt", "lastTouchedAt"] } }, - "mcpTokens": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUsedAt": { - "type": ["string", "null"] - }, - "expiresAt": { - "type": "string" - }, - "revokedAt": { - "type": ["string", "null"] - } - }, - "required": [ - "id", - "name", - "createdAt", - "lastUsedAt", - "expiresAt", - "revokedAt" - ] - } - }, "mcpSessions": { "type": "array", "items": { @@ -825,15 +806,14 @@ "exportedAt", "profile", "memberships", - "apiTokens", + "tokens", "sessions", - "mcpTokens", "mcpSessions", "feedback", "invitations" ] }, - "ApiTokenSummary": { + "AutomationTokenSummary": { "type": "object", "properties": { "id": { @@ -844,24 +824,23 @@ "type": "string", "example": "CI Pipeline" }, - "prefix": { - "type": "string", - "example": "sk-walkeros-abcd" - }, - "origin": { + "tokenPrefix": { "type": "string", - "example": "manual" + "example": "wos_pat_a1b2" }, - "projectId": { - "type": ["string", "null"], - "example": null + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["read", "write"] }, - "scopes": { - "type": ["array", "null"], + "audience": { + "type": "array", "items": { "type": "string" }, - "example": null + "example": ["api", "mcp"] }, "createdAt": { "type": "string", @@ -874,7 +853,7 @@ "example": "2026-01-26T14:30:00.000Z" }, "expiresAt": { - "type": ["string", "null"], + "type": "string", "format": "date-time", "example": "2026-01-26T14:30:00.000Z" }, @@ -887,10 +866,9 @@ "required": [ "id", "name", - "prefix", - "origin", - "projectId", - "scopes", + "tokenPrefix", + "scope", + "audience", "createdAt", "lastUsedAt", "expiresAt", @@ -971,6 +949,18 @@ "updatedAt" ] }, + "DeploySettingsRequest": { + "type": "object", + "properties": { + "flow": { + "type": "string" + }, + "humanText": { + "type": "string", + "maxLength": 4000 + } + } + }, "DeploySettingsResponse": { "type": "object", "properties": { @@ -1332,7 +1322,14 @@ "example": "active" }, "currentVersion": { - "$ref": "#/components/schemas/DeploymentVersionDetail" + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentVersionDetail" + }, + { + "type": "null" + } + ] }, "versions": { "type": "array", @@ -1341,7 +1338,14 @@ } }, "error": { - "$ref": "#/components/schemas/DeploymentError" + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentError" + }, + { + "type": "null" + } + ] }, "recentErrors": { "type": ["array", "null"], @@ -1440,7 +1444,7 @@ ] }, "DeploymentVersionDetail": { - "type": ["object", "null"], + "type": "object", "properties": { "number": { "type": "integer", @@ -1532,7 +1536,7 @@ ] }, "DeploymentError": { - "type": ["object", "null"], + "type": "object", "properties": { "code": { "type": "string" @@ -2103,6 +2107,19 @@ }, "createdBy": { "type": ["string", "null"] + }, + "createdByLabel": { + "type": ["string", "null"] + }, + "rationale": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReleaseRationaleSummary" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -2117,20 +2134,138 @@ "source", "errorCode", "createdAt", + "createdBy", + "createdByLabel" + ] + }, + "ReleaseRationaleSummary": { + "type": "object", + "properties": { + "hasHumanText": { + "type": "boolean" + }, + "hasGeneratedSummary": { + "type": "boolean" + }, + "firstLine": { + "type": ["string", "null"] + } + }, + "required": ["hasHumanText", "hasGeneratedSummary", "firstLine"] + }, + "ReleaseContentResponse": { + "type": "object", + "properties": { + "versionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + }, + "versionNumber": { + "type": "integer", + "exclusiveMinimum": 0, + "example": 22 + }, + "content": { + "$ref": "#/components/schemas/FlowConfig" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "createdBy": { + "type": "string", + "enum": ["user", "auto_save", "restore", "deploy", "preview"] + } + }, + "required": [ + "versionId", + "versionNumber", + "content", + "createdAt", "createdBy" ] }, - "ListVersionAnnotationsResponse": { + "ReleaseDiff": { "type": "object", "properties": { - "annotations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VersionAnnotation" - } + "prevVersionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + }, + "prevVersionNumber": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "text": { + "type": "string" + }, + "contentIdentical": { + "type": "boolean" } }, - "required": ["annotations"] + "required": [ + "prevVersionId", + "prevVersionNumber", + "text", + "contentIdentical" + ] + }, + "ReleaseDetailResponse": { + "type": "object", + "properties": { + "versionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + }, + "versionNumber": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "contentHash": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "createdBy": { + "type": "string" + }, + "rationale": { + "anyOf": [ + { + "$ref": "#/components/schemas/VersionAnnotation" + }, + { + "type": "null" + } + ] + }, + "diff": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReleaseDiff" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "versionId", + "versionNumber", + "contentHash", + "createdAt", + "createdBy", + "rationale", + "diff" + ] }, "VersionAnnotation": { "type": "object", @@ -2170,6 +2305,18 @@ "updatedAt" ] }, + "ListVersionAnnotationsResponse": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionAnnotation" + } + } + }, + "required": ["annotations"] + }, "UpsertVersionAnnotationResponse": { "type": "object", "properties": { @@ -2404,2333 +2551,2223 @@ "messageCount" ] }, - "SummarizeReleaseResponse": { + "ListKnowledgeResponse": { "type": "object", "properties": { - "mode": { - "type": "string", - "enum": ["draft", "check"] - }, - "text": { - "type": "string" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeEntry" + } }, - "cached": { + "hasMoreEntries": { "type": "boolean" - }, - "modelId": { - "type": "string", - "example": "mistral/platform/mistral-large-2512" - }, - "versionNumber": { - "type": "integer" - }, - "prevVersionNumber": { - "type": "integer" } }, - "required": [ - "mode", - "text", - "cached", - "modelId", - "versionNumber", - "prevVersionNumber" - ] + "required": ["entries", "hasMoreEntries"] }, - "StepHistoryResponse": { + "KnowledgeEntry": { + "oneOf": [ + { + "$ref": "#/components/schemas/KnowledgeThread" + }, + { + "$ref": "#/components/schemas/KnowledgeDescription" + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "thread": "#/components/schemas/KnowledgeThread", + "description": "#/components/schemas/KnowledgeDescription" + } + } + }, + "KnowledgeThread": { "type": "object", "properties": { - "step": { + "id": { "type": "string" }, - "flow": { + "anchorKey": { + "type": "string" + }, + "anchorLabel": { + "type": "string" + }, + "frameId": { "type": ["string", "null"] }, - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StepHistoryEntry" - } + "frameName": { + "type": ["string", "null"] }, - "scanned": { - "type": "integer" + "flowId": { + "type": ["string", "null"] }, - "truncated": { - "type": "boolean" + "subjectKey": { + "type": ["string", "null"] }, - "entriesTruncated": { - "type": "boolean" + "spatial": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeSpatial" + }, + { + "type": "null" + } + ] }, - "knownSteps": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "step", - "flow", - "entries", - "scanned", - "truncated", - "entriesTruncated" - ] - }, - "StepHistoryEntry": { - "type": "object", - "properties": { - "versionId": { + "validity": { + "$ref": "#/components/schemas/KnowledgeValidity" + }, + "freshness": { "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$", - "example": "ver_a1b2c3d4" + "enum": ["current", "subject_changed", "unknown"] }, - "versionNumber": { - "type": "integer" + "author": { + "$ref": "#/components/schemas/KnowledgeAuthor" }, - "createdAt": { + "source": { + "type": "string", + "enum": ["tag_mode", "hub", "mcp"] + }, + "updatedAt": { "type": "string", "format": "date-time", "example": "2026-01-26T14:30:00.000Z" }, - "flow": { - "type": ["string", "null"] + "kind": { + "type": "string", + "enum": ["thread"] }, - "change": { + "anchorType": { "type": "string", - "enum": ["added", "removed", "changed"], - "example": "changed" + "enum": [ + "step", + "entity_action", + "release", + "contract", + "tag", + "page" + ], + "example": "tag" }, - "humanText": { - "type": ["string", "null"] + "status": { + "type": "string", + "enum": ["open", "resolved"], + "example": "open" }, - "generatedSummary": { - "type": ["string", "null"] + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "messageCount": { + "type": "integer" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeMessage" + } + }, + "hasMoreMessages": { + "type": "boolean" } }, "required": [ - "versionId", - "versionNumber", + "id", + "anchorKey", + "anchorLabel", + "frameId", + "frameName", + "flowId", + "subjectKey", + "spatial", + "validity", + "freshness", + "author", + "source", + "updatedAt", + "kind", + "anchorType", + "status", "createdAt", - "flow", - "change", - "humanText", - "generatedSummary" + "messageCount" ] }, - "HeartbeatResponse": { + "KnowledgeSpatial": { "type": "object", "properties": { - "ack": { - "type": "boolean", - "enum": [true] - }, - "deploymentId": { - "type": "string", - "pattern": "^dep_[a-zA-Z0-9_-]+$", - "example": "dep_a1b2c3d4" - }, - "action": { - "type": "string", - "enum": ["none", "stop", "update"] - }, - "versionNumber": { - "type": "integer", - "exclusiveMinimum": 0 + "at": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["x", "y"] }, - "bundleUrl": { - "type": "string" + "element": { + "type": "object", + "additionalProperties": {} } }, - "required": ["ack", "deploymentId", "action"] + "required": ["at"] }, - "ObserveTicketRequest": { - "type": "object", - "properties": { - "scope": { + "KnowledgeValidity": { + "oneOf": [ + { "type": "object", "properties": { - "kind": { + "tier": { "type": "string", - "enum": ["session"] + "enum": ["release"] }, - "sessionId": { + "versionId": { + "type": "string" + }, + "versionNumber": { + "type": "integer" + }, + "promoted": { + "type": "boolean" + } + }, + "required": ["tier", "versionId", "versionNumber", "promoted"] + }, + { + "type": "object", + "properties": { + "tier": { "type": "string", - "minLength": 1 + "enum": ["draft"] + }, + "versionId": { + "type": "string" } }, - "required": ["kind", "sessionId"] + "required": ["tier"] + }, + { + "type": "object", + "properties": { + "tier": { + "type": "string", + "enum": ["none"] + } + }, + "required": ["tier"] } - } + ] }, - "ObserveTicketResponse": { + "KnowledgeAuthor": { "type": "object", "properties": { - "ticket": { + "kind": { "type": "string", - "minLength": 1 + "enum": ["user", "preview", "agent"] }, - "observerUrl": { - "type": "string", - "format": "uri" + "id": { + "type": ["string", "null"] + }, + "label": { + "type": "string" } }, - "required": ["ticket", "observerUrl"] + "required": ["kind", "id", "label"] }, - "PreviewResponse": { + "KnowledgeMessage": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$", - "example": "prv_abc123xyz456" - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "flowSettingsId": { - "type": "string" - }, - "projectId": { "type": "string" }, - "bundleUrl": { + "author": { "type": "string", - "format": "uri" - }, - "activationUrl": { - "type": ["string", "null"] + "example": "user_a1b2c3d4" }, - "tagMode": { - "type": "boolean" + "authorLabel": { + "type": "string", + "example": "ayla@elbwalker.com" }, - "createdBy": { + "text": { "type": "string" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "clientMessageId": { + "type": ["string", "null"] } }, "required": [ "id", - "flowId", - "flowSettingsId", - "projectId", - "bundleUrl", - "activationUrl", - "tagMode", - "createdBy", - "createdAt" + "author", + "authorLabel", + "text", + "createdAt", + "clientMessageId" ] }, - "CreatePreviewResponse": { - "allOf": [ - { - "$ref": "#/components/schemas/PreviewResponse" + "KnowledgeDescription": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "type": "object", - "properties": { - "grant": { - "type": ["string", "null"] - } - }, - "required": ["grant"] - } - ] - }, - "ListPreviewsResponse": { - "type": "object", - "properties": { - "previews": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PreviewResponse" - } + "anchorKey": { + "type": "string" }, - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["previews", "total"] - }, - "CreatePreviewRequest": { - "type": "object", - "properties": { - "flowSettingsId": { + "anchorLabel": { "type": "string" }, - "source": { - "oneOf": [ + "frameId": { + "type": ["string", "null"] + }, + "frameName": { + "type": ["string", "null"] + }, + "flowId": { + "type": ["string", "null"] + }, + "subjectKey": { + "type": ["string", "null"] + }, + "spatial": { + "anyOf": [ { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["draft"] - } - }, - "required": ["kind"] + "$ref": "#/components/schemas/KnowledgeSpatial" }, { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["deployment-version"] - }, - "deploymentVersionId": { - "type": "string" - } - }, - "required": ["kind", "deploymentVersionId"] + "type": "null" } ] }, - "tagMode": { - "type": "boolean", - "default": true - } - }, - "required": ["flowSettingsId"] - }, - "MintGrantRequest": { - "type": "object", - "properties": { - "origins": { - "type": "array", - "items": { - "type": "string", - "maxLength": 253, - "example": "https://shop.example.com" - }, - "minItems": 1, - "maxItems": 5 + "validity": { + "$ref": "#/components/schemas/KnowledgeValidity" }, - "sessionId": { - "type": "string" - } - }, - "required": ["origins"] - }, - "MintGrantResponse": { - "type": "object", - "properties": { - "grant": { - "type": "string" + "freshness": { + "type": "string", + "enum": ["current", "subject_changed", "unknown"] }, - "activationUrl": { + "author": { + "$ref": "#/components/schemas/KnowledgeAuthor" + }, + "source": { "type": "string", - "format": "uri" + "enum": ["tag_mode", "hub", "mcp"] }, - "sessionExpiresAt": { + "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "sessionGrant": { - "type": "string" + "kind": { + "type": "string", + "enum": ["description"] }, - "sessionId": { + "anchorType": { + "type": "string", + "enum": ["tag", "page"], + "example": "tag" + }, + "body": { "type": "string" } }, - "required": ["grant", "activationUrl", "sessionExpiresAt"] + "required": [ + "id", + "anchorKey", + "anchorLabel", + "frameId", + "frameName", + "flowId", + "subjectKey", + "spatial", + "validity", + "freshness", + "author", + "source", + "updatedAt", + "kind", + "anchorType", + "body" + ] }, - "ObserveSessionResponse": { + "KnowledgeThreadResponse": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" - }, - "projectId": { "type": "string" }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "anchorKey": { + "type": "string" }, - "status": { + "anchorLabel": { "type": "string" }, - "errorMessage": { + "frameId": { "type": ["string", "null"] }, - "configSnapshot": { - "type": "object", - "additionalProperties": {} - }, - "observedFlowName": { + "frameName": { "type": ["string", "null"] }, - "serverFlowName": { + "flowId": { "type": ["string", "null"] }, - "serverEndpoint": { + "subjectKey": { "type": ["string", "null"] }, - "web": { - "$ref": "#/components/schemas/ObserveSessionWeb" + "spatial": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeSpatial" + }, + { + "type": "null" + } + ] }, - "server": { - "$ref": "#/components/schemas/ObserveSessionServer" + "validity": { + "$ref": "#/components/schemas/KnowledgeValidity" }, - "expiresAt": { + "freshness": { "type": "string", - "format": "date-time" + "enum": ["current", "subject_changed", "unknown"] }, - "recordsReceived": { - "type": "integer", - "minimum": 0 + "author": { + "$ref": "#/components/schemas/KnowledgeAuthor" }, - "createdBy": { - "type": "string" + "source": { + "type": "string", + "enum": ["tag_mode", "hub", "mcp"] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "kind": { + "type": "string", + "enum": ["thread"] + }, + "anchorType": { + "type": "string", + "enum": [ + "step", + "entity_action", + "release", + "contract", + "tag", + "page" + ], + "example": "tag" + }, + "status": { + "type": "string", + "enum": ["open", "resolved"], + "example": "open" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "messageCount": { + "type": "integer" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeMessage" + } + }, + "hasMoreMessages": { + "type": "boolean" } }, "required": [ "id", - "projectId", + "anchorKey", + "anchorLabel", + "frameId", + "frameName", "flowId", + "subjectKey", + "spatial", + "validity", + "freshness", + "author", + "source", + "updatedAt", + "kind", + "anchorType", "status", - "errorMessage", - "configSnapshot", - "observedFlowName", - "serverFlowName", - "serverEndpoint", - "web", - "server", - "expiresAt", - "recordsReceived", - "createdBy", - "createdAt" + "createdAt", + "messageCount" ] }, - "ObserveSessionWeb": { - "type": ["object", "null"], + "KnowledgeDescriptionResponse": { + "type": "object", "properties": { - "activationUrl": { - "type": ["string", "null"], - "format": "uri" + "id": { + "type": "string" }, - "credential": { + "anchorKey": { "type": "string" }, - "previewEnabled": { - "type": "boolean" + "anchorLabel": { + "type": "string" }, - "bundleUrl": { + "frameId": { + "type": ["string", "null"] + }, + "frameName": { + "type": ["string", "null"] + }, + "flowId": { + "type": ["string", "null"] + }, + "subjectKey": { + "type": ["string", "null"] + }, + "spatial": { + "anyOf": [ + { + "$ref": "#/components/schemas/KnowledgeSpatial" + }, + { + "type": "null" + } + ] + }, + "validity": { + "$ref": "#/components/schemas/KnowledgeValidity" + }, + "freshness": { "type": "string", - "format": "uri" + "enum": ["current", "subject_changed", "unknown"] }, - "url": { + "author": { + "$ref": "#/components/schemas/KnowledgeAuthor" + }, + "source": { "type": "string", - "format": "uri" + "enum": ["tag_mode", "hub", "mcp"] }, - "binding": { - "type": "string" - } - }, - "required": [ - "activationUrl", - "credential", - "previewEnabled", - "bundleUrl" - ] - }, - "ObserveSessionServer": { - "type": ["object", "null"], - "properties": { - "endpoint": { - "type": ["string", "null"], - "format": "uri" + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "env": { - "$ref": "#/components/schemas/ObserveSessionServerEnv" - } - }, - "required": ["endpoint", "env"] - }, - "ObserveSessionServerEnv": { - "type": "object", - "properties": { - "WALKEROS_OBSERVER_URL": { + "kind": { "type": "string", - "format": "uri" + "enum": ["description"] }, - "WALKEROS_DEPLOYMENT_ID": { - "type": "string" + "anchorType": { + "type": "string", + "enum": ["tag", "page"], + "example": "tag" }, - "WALKEROS_INGEST_TOKEN": { + "body": { "type": "string" } }, "required": [ - "WALKEROS_OBSERVER_URL", - "WALKEROS_DEPLOYMENT_ID", - "WALKEROS_INGEST_TOKEN" + "id", + "anchorKey", + "anchorLabel", + "frameId", + "frameName", + "flowId", + "subjectKey", + "spatial", + "validity", + "freshness", + "author", + "source", + "updatedAt", + "kind", + "anchorType", + "body" ] }, - "ObserveSessionJourneysResponse": { + "FrameInput": { "type": "object", "properties": { - "sessionId": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" - }, - "flowId": { + "name": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "minLength": 1, + "maxLength": 255 }, - "assembledAt": { - "type": "string", - "format": "date-time" + "parentId": { + "type": ["string", "null"], + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" }, - "journeys": { + "placements": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} - } + "$ref": "#/components/schemas/FramePlacement" + }, + "minItems": 1 }, - "gaps": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "size": { + "$ref": "#/components/schemas/PlanSize" }, - "unattributed": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "marks": { + "type": "object", + "additionalProperties": {} + }, + "extends": { + "type": ["string", "null"], + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" + }, + "source": { + "$ref": "#/components/schemas/FrameSource" + }, + "origin": { + "type": "string", + "enum": ["drawn", "imported", "observed"] + }, + "flowId": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 255 } }, - "required": ["sessionId", "flowId", "assembledAt", "journeys", "gaps"] + "required": [ + "name", + "parentId", + "placements", + "size", + "marks", + "extends", + "source", + "origin", + "flowId" + ] }, - "CreateObserveSessionRequest": { + "FramePlacement": { "type": "object", "properties": { - "settingsName": { + "id": { "type": "string", - "minLength": 1 - }, - "force": { - "type": "boolean" + "pattern": "^pl_[A-Za-z0-9_-]{21}$" }, - "replace": { - "type": "boolean" - }, - "level": { - "$ref": "#/components/schemas/ObserveLevel" + "rect": { + "$ref": "#/components/schemas/PlanRect" }, - "origins": { - "type": "array", - "items": { - "type": "string", - "maxLength": 253, - "example": "https://shop.example.com" - }, - "maxItems": 20 + "selector": { + "type": "string", + "maxLength": 2048 }, - "tagMode": { - "type": "boolean" + "anchor": { + "type": "object", + "additionalProperties": {} } }, - "required": ["settingsName"] - }, - "ObserveLevel": { - "type": "string", - "enum": ["off", "standard", "trace"] + "required": ["id", "rect"] }, - "ObserveSessionHeartbeatResponse": { + "PlanRect": { "type": "object", "properties": { - "ok": { - "type": "boolean", - "enum": [true] + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "w": { + "type": "number" + }, + "h": { + "type": "number" } }, - "required": ["ok"] - }, - "SecretName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Z_][A-Z0-9_]*$" + "required": ["x", "y", "w", "h"] }, - "CreateSecretRequest": { + "PlanSize": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Z_][A-Z0-9_]*$" + "width": { + "type": "number", + "exclusiveMinimum": 0 }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 + "height": { + "type": "number", + "exclusiveMinimum": 0 } }, - "required": ["name", "value"] + "required": ["width", "height"] }, - "UpdateSecretRequest": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 + "FrameSource": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["page"] + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": ["kind", "key", "url"] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["figma"] + }, + "fileKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "nodeId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["kind", "fileKey", "nodeId"] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["image"] + } + }, + "required": ["kind"] + }, + { + "type": "null" } - }, - "required": ["value"] + ] }, - "SecretSummary": { + "Frame": { "type": "object", "properties": { "id": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" + }, + "projectId": { "type": "string" }, "name": { "type": "string" }, + "parentId": { + "type": ["string", "null"] + }, + "placements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FramePlacement" + } + }, + "size": { + "$ref": "#/components/schemas/PlanSize" + }, + "marks": { + "type": "object", + "additionalProperties": {} + }, + "extends": { + "type": ["string", "null"] + }, + "source": { + "$ref": "#/components/schemas/FrameSource" + }, + "origin": { + "type": "string", + "enum": ["drawn", "imported", "observed"] + }, "flowId": { - "type": "string" + "type": ["string", "null"] + }, + "screenshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/FrameScreenshot" + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "integer" }, "createdAt": { - "type": ["string", "null"], - "format": "date-time" + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] - }, - "SecretListResponse": { - "type": "object", - "properties": { - "secrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] - } - } - }, - "required": ["secrets"] - }, - "FeedbackRequest": { - "type": "object", - "properties": { - "text": { "type": "string", - "minLength": 1, - "maxLength": 5000, - "example": "The MCP flow_bundle tool is great but slow on large configs." + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "userId": { - "type": "string", - "format": "email", - "example": "alex@example.com" + "createdBy": { + "type": "string" }, - "projectId": { - "type": "string", - "maxLength": 255, - "example": "proj_abc123" + "updatedBy": { + "type": "string" }, - "version": { - "type": "string", - "maxLength": 100, - "example": "0.4.2" + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["text"] + "required": [ + "id", + "projectId", + "name", + "parentId", + "placements", + "size", + "marks", + "extends", + "source", + "origin", + "flowId", + "screenshot", + "version", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy", + "deletedAt" + ] }, - "FeedbackResponse": { + "FrameScreenshot": { "type": "object", "properties": { - "ok": { - "type": "boolean", - "enum": [true] + "assetId": { + "type": "string" }, - "id": { + "capturedAt": { "type": "string", - "example": "fb_abcdef1234567890" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "size": { + "$ref": "#/components/schemas/PlanSize" + }, + "dpr": { + "type": "number", + "exclusiveMinimum": 0 + }, + "capturedRect": { + "$ref": "#/components/schemas/PlanRect" } }, - "required": ["ok", "id"] + "required": ["assetId", "capturedAt", "size", "dpr", "capturedRect"] }, - "StepExample": { + "FrameLean": { "type": "object", "properties": { - "title": { - "type": "string" + "id": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" }, - "description": { + "projectId": { "type": "string" }, - "public": { - "type": "boolean" - }, - "trigger": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "options": {} - } - }, - "mapping": {}, - "command": { + "name": { "type": "string" }, - "in": { - "$ref": "#/components/schemas/StepExampleEvent" + "parentId": { + "type": ["string", "null"] }, - "out": { + "placements": { "type": "array", "items": { - "type": "array", - "items": {} + "$ref": "#/components/schemas/FramePlacement" } - } - }, - "required": ["in"] - }, - "StepExampleEvent": { - "type": "object", - "properties": { - "entity": { - "type": "string" }, - "action": { - "type": "string" + "size": { + "$ref": "#/components/schemas/PlanSize" }, - "data": { - "type": "object", - "additionalProperties": {} + "extends": { + "type": ["string", "null"] }, - "context": { - "type": "object", - "additionalProperties": {} + "source": { + "$ref": "#/components/schemas/FrameSource" }, - "globals": { - "type": "object", - "additionalProperties": {} + "origin": { + "type": "string", + "enum": ["drawn", "imported", "observed"] }, - "custom": { - "type": "object", - "additionalProperties": {} + "flowId": { + "type": ["string", "null"] }, - "id": { - "type": "string" + "screenshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/FrameScreenshot" + }, + { + "type": "null" + } + ] }, - "timestamp": { - "type": "string" + "version": { + "type": "integer" }, - "timing": { - "type": "object", - "additionalProperties": {} + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "user": { - "type": "object", - "additionalProperties": {} + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "version": { + "createdBy": { "type": "string" }, - "source": { + "updatedBy": { "type": "string" }, - "trigger": { - "type": "string" + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } - } + }, + "required": [ + "id", + "projectId", + "name", + "parentId", + "placements", + "size", + "extends", + "source", + "origin", + "flowId", + "screenshot", + "version", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy", + "deletedAt" + ] }, - "CreateStepExampleRequest": { + "FrameListResponse": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "public": { - "type": "boolean" - }, - "trigger": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "options": {} + "frames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Frame" } - }, - "mapping": {}, - "command": { - "type": "string" - }, - "name": { - "type": "string", - "minLength": 1 - }, - "event": { - "$ref": "#/components/schemas/StepExampleEvent" - }, - "out": { + } + }, + "required": ["frames"] + }, + "FrameLeanListResponse": { + "type": "object", + "properties": { + "frames": { "type": "array", "items": { - "type": "array", - "items": {} + "$ref": "#/components/schemas/FrameLean" } } }, - "required": ["name", "event"] + "required": ["frames"] }, - "EditStepExampleRequest": { + "PutFrameResponse": { "type": "object", "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "public": { - "type": "boolean" - }, - "trigger": { + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "FrameConflictResponse": { + "type": "object", + "properties": { + "error": { "type": "object", "properties": { - "type": { - "type": "string" + "code": { + "type": "string", + "enum": ["FRAME_VERSION_CONFLICT"] }, - "options": {} - } + "message": { + "type": "string" + } + }, + "required": ["code", "message"] }, - "mapping": {}, - "command": { - "type": "string" + "head": { + "$ref": "#/components/schemas/Frame" + } + }, + "required": ["error", "head"] + }, + "CanvasDocument": { + "type": "object", + "properties": { + "v": { + "type": "number", + "enum": [1] }, - "name": { - "type": "string", - "minLength": 1 + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CanvasNodeEntry" + }, + "maxItems": 2000 }, - "event": { - "$ref": "#/components/schemas/StepExampleEvent" + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CanvasEdgeEntry" + }, + "maxItems": 4000 }, - "out": { + "hidden": { "type": "array", "items": { - "type": "array", - "items": {} - } + "type": "string", + "maxLength": 200 + }, + "maxItems": 4000 } }, - "required": ["name"] + "required": ["v", "nodes", "edges", "hidden"] }, - "StepExamplesResponse": { + "CanvasNodeEntry": { "type": "object", "properties": { - "examples": { + "kind": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "position": { + "$ref": "#/components/schemas/CanvasPoint" + }, + "parent": { + "type": "string", + "maxLength": 200 + }, + "size": { "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/StepExample" - } + "properties": { + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["width", "height"] + }, + "label": { + "type": "string", + "maxLength": 255 } }, - "required": ["examples"] + "required": ["kind", "ref", "position"] }, - "ObserveStepExample": { + "CanvasPoint": { "type": "object", "properties": { - "in": {}, - "out": {}, - "mapping": {}, - "title": { - "type": "string" + "x": { + "type": "number" }, - "description": { - "type": "string" + "y": { + "type": "number" } - } + }, + "required": ["x", "y"] }, - "ObserveSaveExampleRequest": { + "CanvasEdgeEntry": { "type": "object", "properties": { - "stepPath": { + "id": { "type": "string", - "minLength": 1 + "pattern": "^edg_[A-Za-z0-9_-]{21}$" }, - "scenario": { + "kind": { "type": "string", - "minLength": 1 + "enum": ["navigation"] }, - "example": { - "$ref": "#/components/schemas/ObserveStepExample" - } - }, - "required": ["stepPath", "scenario", "example"] - }, - "SecretValuesResponse": { - "type": "object", - "properties": { - "values": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "from": { + "type": "string", + "maxLength": 200 + }, + "to": { + "type": "string", + "maxLength": 200 + }, + "label": { + "type": "string", + "maxLength": 255 } }, - "required": ["values"] + "required": ["id", "kind", "from", "to"] }, - "ServiceAccountSummary": { + "Canvas": { "type": "object", "properties": { "id": { + "type": "string", + "pattern": "^cnv_[A-Za-z0-9_-]{21}$", + "example": "cnv_V1StGXR8Z5jdHi6BmyT7K" + }, + "projectId": { "type": "string" }, "name": { "type": "string" }, - "role": { - "type": "string", - "enum": ["member", "deployer", "viewer"] - }, - "email": { - "type": "string" + "document": { + "$ref": "#/components/schemas/CanvasDocument" }, - "description": { - "type": ["string", "null"] + "version": { + "type": "integer" }, "createdAt": { "type": "string", "format": "date-time", "example": "2026-01-26T14:30:00.000Z" - } - }, - "required": ["id", "name", "role", "email", "description", "createdAt"] - }, - "CreateServiceAccountRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 }, - "role": { + "updatedAt": { "type": "string", - "enum": ["member", "deployer", "viewer"] + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "description": { - "type": "string", - "maxLength": 1000 + "createdBy": { + "type": "string" + }, + "updatedBy": { + "type": "string" } }, - "required": ["name", "role"] + "required": [ + "id", + "projectId", + "name", + "document", + "version", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy" + ] }, - "UpdateServiceAccountRequest": { + "CanvasLean": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "description": { + "id": { "type": "string", - "maxLength": 1000 + "pattern": "^cnv_[A-Za-z0-9_-]{21}$", + "example": "cnv_V1StGXR8Z5jdHi6BmyT7K" }, - "role": { - "type": "string", - "enum": ["member", "deployer", "viewer"] - } - } - }, - "CreateServiceAccountResponse": { - "type": "object", - "properties": { - "id": { + "projectId": { "type": "string" }, "name": { "type": "string" }, - "role": { - "type": "string", - "enum": ["member", "deployer", "viewer"] + "version": { + "type": "integer" }, - "email": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "token": { - "type": "string" + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "tokenId": { + "createdBy": { "type": "string" }, - "tokenPrefix": { + "updatedBy": { "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" } }, "required": [ "id", + "projectId", "name", - "role", - "email", - "token", - "tokenId", - "tokenPrefix", - "createdAt" + "version", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy" ] }, - "ListServiceAccountsResponse": { + "CanvasListResponse": { "type": "object", "properties": { - "serviceAccounts": { + "canvases": { "type": "array", "items": { - "$ref": "#/components/schemas/ServiceAccountSummary" + "$ref": "#/components/schemas/CanvasLean" } - }, - "total": { - "type": "number" } }, - "required": ["serviceAccounts", "total"] + "required": ["canvases"] }, - "CreateSaTokenRequest": { + "PutCanvasResponse": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "version": { + "type": "integer" + } + }, + "required": ["version"] + }, + "CanvasConflictResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": ["CANVAS_VERSION_CONFLICT"] + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"] }, - "expiresInDays": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 365 + "head": { + "$ref": "#/components/schemas/Canvas" } }, - "required": ["name"] + "required": ["error", "head"] }, - "SaTokenSummary": { + "SummarizeReleaseResponse": { "type": "object", "properties": { - "id": { - "type": "string" + "mode": { + "type": "string", + "enum": ["draft", "check"] }, - "name": { + "text": { "type": "string" }, - "prefix": { - "type": "string" + "cached": { + "type": "boolean" }, - "createdAt": { + "modelId": { "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "example": "mistral/platform/mistral-large-2512" }, - "lastUsedAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" - }, - "expiresAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "versionNumber": { + "type": "integer" }, - "revokedAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "prevVersionNumber": { + "type": "integer" } }, "required": [ - "id", - "name", - "prefix", - "createdAt", - "lastUsedAt", - "expiresAt", - "revokedAt" + "mode", + "text", + "cached", + "modelId", + "versionNumber", + "prevVersionNumber" ] }, - "CreateSaTokenResponse": { + "StepHistoryResponse": { "type": "object", "properties": { - "id": { + "step": { "type": "string" }, - "name": { - "type": "string" + "flow": { + "type": ["string", "null"] }, - "token": { - "type": "string" + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StepHistoryEntry" + } }, - "prefix": { - "type": "string" + "scanned": { + "type": "integer" }, - "expiresAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "truncated": { + "type": "boolean" }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" - } - }, - "required": ["id", "name", "token", "prefix", "expiresAt", "createdAt"] - }, - "ListSaTokensResponse": { - "type": "object", - "properties": { - "tokens": { + "entriesTruncated": { + "type": "boolean" + }, + "knownSteps": { "type": "array", "items": { - "$ref": "#/components/schemas/SaTokenSummary" + "type": "string" } - }, - "total": { - "type": "number" } }, - "required": ["tokens", "total"] + "required": [ + "step", + "flow", + "entries", + "scanned", + "truncated", + "entriesTruncated" + ] }, - "Invitation": { + "StepHistoryEntry": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "email": { + "versionId": { "type": "string", - "format": "email", - "example": "user@example.com" + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" }, - "role": { - "type": "string", - "enum": ["admin", "member", "deployer", "viewer"], - "default": "member", - "example": "member" + "versionNumber": { + "type": "integer" }, - "status": { + "createdAt": { "type": "string", - "enum": ["pending", "accepted", "declined", "expired", "cancelled"] + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "invitedBy": { + "flow": { "type": ["string", "null"] }, - "expiresAt": { + "change": { "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "enum": ["added", "removed", "changed"], + "example": "changed" }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "humanText": { + "type": ["string", "null"] + }, + "generatedSummary": { + "type": ["string", "null"] } }, "required": [ - "id", - "email", - "status", - "invitedBy", - "expiresAt", - "createdAt" + "versionId", + "versionNumber", + "createdAt", + "flow", + "change", + "humanText", + "generatedSummary" ] }, - "CreateInvitationRequest": { + "HeartbeatResponse": { "type": "object", "properties": { - "email": { + "ack": { + "type": "boolean", + "enum": [true] + }, + "deploymentId": { "type": "string", - "format": "email", - "example": "user@example.com" + "pattern": "^dep_[a-zA-Z0-9_-]+$", + "example": "dep_a1b2c3d4" }, - "role": { + "action": { "type": "string", - "enum": ["admin", "member", "deployer", "viewer"], - "default": "member", - "example": "member" + "enum": ["none", "stop", "update"] + }, + "versionNumber": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "bundleUrl": { + "type": "string" } }, - "required": ["email"] + "required": ["ack", "deploymentId", "action"] }, - "CreateInvitationResponse": { + "ObserveTicketRequest": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email", - "example": "user@example.com" - }, - "role": { - "type": "string", - "enum": ["admin", "member", "deployer", "viewer"], - "default": "member", - "example": "member" - }, - "status": { - "type": "string", - "enum": ["pending"] - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "scope": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["session"] + }, + "sessionId": { + "type": "string", + "minLength": 1 + } + }, + "required": ["kind", "sessionId"] } - }, - "required": ["id", "email", "status", "expiresAt", "createdAt"] + } }, - "ListInvitationsResponse": { + "ObserveTicketResponse": { "type": "object", "properties": { - "invitations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Invitation" - } + "ticket": { + "type": "string", + "minLength": 1 }, - "total": { - "type": "number" + "observerUrl": { + "type": "string", + "format": "uri" } }, - "required": ["invitations", "total"] + "required": ["ticket", "observerUrl"] }, - "InvitationPreview": { + "PreviewResponse": { "type": "object", "properties": { - "projectName": { - "type": "string" - }, - "email": { + "id": { "type": "string", - "format": "email", - "example": "user@example.com" + "pattern": "^prv_[a-z0-9]+$", + "example": "prv_abc123xyz456" }, - "role": { + "flowId": { "type": "string", - "enum": ["admin", "member", "deployer", "viewer"], - "default": "member", - "example": "member" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, - "status": { - "type": "string", - "enum": ["pending", "accepted", "declined", "expired", "cancelled"] + "flowSettingsId": { + "type": "string" }, - "expiresAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "projectId": { + "type": "string" }, - "invitedByEmail": { + "bundleUrl": { "type": "string", - "format": "email", - "example": "user@example.com" + "format": "uri" + }, + "activationUrl": { + "type": ["string", "null"] + }, + "tagMode": { + "type": "boolean" + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" } }, "required": [ - "projectName", - "email", - "status", - "expiresAt", - "invitedByEmail" + "id", + "flowId", + "flowSettingsId", + "projectId", + "bundleUrl", + "activationUrl", + "tagMode", + "createdBy", + "createdAt" ] }, - "AcceptInvitationResponse": { + "CreatePreviewResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/PreviewResponse" + }, + { + "type": "object", + "properties": { + "grant": { + "type": ["string", "null"] + } + }, + "required": ["grant"] + } + ] + }, + "ListPreviewsResponse": { "type": "object", "properties": { - "projectId": { + "previews": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PreviewResponse" + } + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["previews", "total"] + }, + "CreatePreviewRequest": { + "type": "object", + "properties": { + "flowSettingsId": { "type": "string" }, - "projectName": { + "source": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["draft"] + } + }, + "required": ["kind"] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["deployment-version"] + }, + "deploymentVersionId": { + "type": "string" + } + }, + "required": ["kind", "deploymentVersionId"] + } + ] + }, + "tagMode": { + "type": "boolean", + "default": true + } + }, + "required": ["flowSettingsId"] + }, + "MintGrantRequest": { + "type": "object", + "properties": { + "origins": { + "type": "array", + "items": { + "type": "string", + "maxLength": 253, + "example": "https://shop.example.com" + }, + "minItems": 1, + "maxItems": 5 + }, + "sessionId": { + "type": "string" + } + }, + "required": ["origins"] + }, + "MintGrantResponse": { + "type": "object", + "properties": { + "grant": { "type": "string" }, - "role": { + "activationUrl": { "type": "string", - "enum": ["admin", "member", "deployer", "viewer"], - "default": "member", - "example": "member" + "format": "uri" }, - "alreadyMember": { - "type": "boolean" + "sessionExpiresAt": { + "type": "string", + "format": "date-time" + }, + "sessionGrant": { + "type": "string" + }, + "sessionId": { + "type": "string" } }, - "required": ["projectId", "projectName", "alreadyMember"] + "required": ["grant", "activationUrl", "sessionExpiresAt"] }, - "TelemetryEvent": { + "ObserveSessionResponse": { "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" }, - "name": { + "projectId": { "type": "string" }, - "entity": { - "type": "string" + "flowId": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, - "action": { + "status": { "type": "string" }, - "data": { - "type": "object", - "additionalProperties": {} + "errorMessage": { + "type": ["string", "null"] }, - "context": { + "configSnapshot": { "type": "object", "additionalProperties": {} }, - "globals": { - "type": "object", - "additionalProperties": {} + "observedFlowName": { + "type": ["string", "null"] }, - "custom": { - "type": "object", - "additionalProperties": {} + "serverFlowName": { + "type": ["string", "null"] }, - "user": { - "type": "object", - "properties": { - "device": { - "type": "string" - }, - "session": { - "type": "string" - }, - "os": { - "type": "string" - }, - "osVersion": { - "type": "string" - }, - "node": { - "type": "string" + "serverEndpoint": { + "type": ["string", "null"] + }, + "web": { + "anyOf": [ + { + "$ref": "#/components/schemas/ObserveSessionWeb" }, - "language": { - "type": "string" + { + "type": "null" + } + ] + }, + "server": { + "anyOf": [ + { + "$ref": "#/components/schemas/ObserveSessionServer" }, - "timezone": { - "type": "string" + { + "type": "null" } - }, - "required": [ - "device", - "os", - "osVersion", - "node", - "language", - "timezone" - ], - "additionalProperties": {} + ] }, - "nested": { - "type": "array", - "items": {} + "expiresAt": { + "type": "string", + "format": "date-time" }, - "consent": { - "type": "object", - "additionalProperties": { - "type": "boolean" - } + "recordsReceived": { + "type": "integer", + "minimum": 0 }, - "trigger": { + "createdBy": { "type": "string" }, - "timestamp": { - "type": "number" - }, - "timing": { - "type": "number" - }, - "source": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["cli", "mcp"] - }, - "platform": { - "type": "string" - }, - "release": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "version": { - "type": "string" - }, - "schema": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": ["type"], - "additionalProperties": {} + "createdAt": { + "type": "string", + "format": "date-time" } }, "required": [ "id", - "name", - "entity", - "action", - "data", - "context", - "globals", - "custom", - "user", - "nested", - "consent", - "trigger", - "timestamp", - "timing", - "source" + "projectId", + "flowId", + "status", + "errorMessage", + "configSnapshot", + "observedFlowName", + "serverFlowName", + "serverEndpoint", + "web", + "server", + "expiresAt", + "recordsReceived", + "createdBy", + "createdAt" ] }, - "UpsertBillingDetailsRequest": { + "ObserveSessionWeb": { "type": "object", "properties": { - "companyName": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "address": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "address2": { - "type": "string", - "maxLength": 255 - }, - "postalCode": { - "type": "string", - "minLength": 1, - "maxLength": 20 + "activationUrl": { + "type": ["string", "null"], + "format": "uri" }, - "city": { - "type": "string", - "minLength": 1, - "maxLength": 255 + "credential": { + "type": "string" }, - "country": { - "type": "string", - "minLength": 2, - "maxLength": 2 + "previewEnabled": { + "type": "boolean" }, - "vatId": { + "bundleUrl": { "type": "string", - "maxLength": 50 + "format": "uri" }, - "invoiceEmail": { + "url": { "type": "string", - "format": "email" + "format": "uri" }, - "contactName": { - "type": "string", - "maxLength": 255 + "binding": { + "type": "string" } }, "required": [ - "companyName", - "address", - "postalCode", - "city", - "country", - "invoiceEmail" + "activationUrl", + "credential", + "previewEnabled", + "bundleUrl" ] }, - "BillingDetailsResponse": { + "ObserveSessionServer": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "projectId": { - "type": "string" - }, - "companyName": { - "type": "string" - }, - "address": { - "type": "string" - }, - "address2": { - "type": ["string", "null"] - }, - "postalCode": { - "type": "string" + "endpoint": { + "type": ["string", "null"], + "format": "uri" }, - "city": { - "type": "string" + "env": { + "$ref": "#/components/schemas/ObserveSessionServerEnv" + } + }, + "required": ["endpoint", "env"] + }, + "ObserveSessionServerEnv": { + "type": "object", + "properties": { + "WALKEROS_OBSERVER_URL": { + "type": "string", + "format": "uri" }, - "country": { + "WALKEROS_DEPLOYMENT_ID": { "type": "string" }, - "vatId": { - "type": ["string", "null"] - }, - "invoiceEmail": { + "WALKEROS_INGEST_TOKEN": { "type": "string" - }, - "contactName": { - "type": ["string", "null"] - }, - "taxTreatment": { - "type": "string", - "enum": ["reverse_charge", "domestic", "export", "eu_standard"] - }, - "viesStatus": { - "type": "string", - "enum": ["verified", "invalid", "unavailable", "not_checked"] - }, - "viesCompanyName": { - "type": ["string", "null"] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" } }, "required": [ - "id", - "projectId", - "companyName", - "address", - "address2", - "postalCode", - "city", - "country", - "vatId", - "invoiceEmail", - "contactName", - "taxTreatment", - "viesStatus", - "viesCompanyName", - "createdAt", - "updatedAt" + "WALKEROS_OBSERVER_URL", + "WALKEROS_DEPLOYMENT_ID", + "WALKEROS_INGEST_TOKEN" ] }, - "DeployedContentResponse": { + "ObserveSessionJourneysResponse": { "type": "object", "properties": { - "deploymentId": { - "type": ["string", "null"] - }, - "versionNumber": { - "type": ["integer", "null"], - "exclusiveMinimum": 0 - }, - "status": { - "type": ["string", "null"] + "sessionId": { + "type": "string", + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" }, - "flowSettingsName": { - "type": ["string", "null"] + "flowId": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, - "publishedAt": { - "type": ["string", "null"], + "assembledAt": { + "type": "string", "format": "date-time" }, - "content": {} - }, - "required": [ - "deploymentId", - "versionNumber", - "status", - "flowSettingsName", - "publishedAt" - ] - }, - "ListHeartbeatsResponse": { - "type": "object", - "properties": { - "records": { + "journeys": { "type": "array", "items": { - "$ref": "#/components/schemas/HeartbeatRecord" + "type": "object", + "additionalProperties": {} } }, - "total": { - "type": "integer", - "minimum": 0 - }, - "limit": { - "type": "integer", - "exclusiveMinimum": 0 + "gaps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } }, - "offset": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["records", "total", "limit", "offset"] - }, - "HeartbeatRecord": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "instanceId": { - "type": ["string", "null"] - }, - "cliVersion": { - "type": ["string", "null"] - }, - "configVersion": { - "type": ["integer", "null"] - }, - "mode": { - "type": ["string", "null"] - }, - "uptime": { - "type": ["integer", "null"] - }, - "eventsIn": { - "type": ["integer", "null"], - "minimum": 0 - }, - "eventsOut": { - "type": ["integer", "null"], - "minimum": 0 - }, - "eventsFailed": { - "type": ["integer", "null"], - "minimum": 0 - }, - "perDestinationBreakdown": {}, - "receivedAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "instanceId", - "cliVersion", - "configVersion", - "mode", - "uptime", - "eventsIn", - "eventsOut", - "eventsFailed", - "receivedAt" - ] - }, - "RotateIngestTokenResponse": { - "type": "object", - "properties": { - "ingestToken": { - "type": "string" + "unattributed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } } }, - "required": ["ingestToken"] + "required": ["sessionId", "flowId", "assembledAt", "journeys", "gaps"] }, - "DeploymentUsageResponse": { + "CreateObserveSessionRequest": { "type": "object", "properties": { - "totalEventsIn": { - "type": "integer", - "minimum": 0 - }, - "totalEventsOut": { - "type": "integer", - "minimum": 0 - }, - "totalEventsFailed": { - "type": "integer", - "minimum": 0 - }, - "totalInstances": { - "type": "integer", - "minimum": 0 - }, - "heartbeatCount": { - "type": "integer", - "minimum": 0 - }, - "from": { + "settingsName": { "type": "string", - "format": "date-time" + "minLength": 1 }, - "to": { - "type": "string", - "format": "date-time" + "force": { + "type": "boolean" }, - "averageThroughputPerHour": { - "type": "integer", - "minimum": 0 + "replace": { + "type": "boolean" }, - "period": { - "type": "string" + "level": { + "$ref": "#/components/schemas/ObserveLevel" }, - "buckets": { + "origins": { "type": "array", "items": { - "$ref": "#/components/schemas/UsageBucket" - } + "type": "string", + "maxLength": 253, + "example": "https://shop.example.com" + }, + "maxItems": 20 }, - "destinations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UsageDestination" - } + "tagMode": { + "type": "boolean" } }, - "required": [ - "totalEventsIn", - "totalEventsOut", - "totalEventsFailed", - "totalInstances", - "heartbeatCount", - "from", - "to", - "averageThroughputPerHour", - "period", - "buckets" - ] + "required": ["settingsName"] }, - "UsageBucket": { + "ObserveLevel": { + "type": "string", + "enum": ["off", "standard", "trace"] + }, + "ObserveSessionHeartbeatResponse": { "type": "object", "properties": { - "bucket": { - "type": "string", - "format": "date-time" - }, - "eventsIn": { - "type": "integer", - "minimum": 0 - }, - "eventsOut": { - "type": "integer", - "minimum": 0 - }, - "eventsFailed": { - "type": "integer", - "minimum": 0 - }, - "instances": { - "type": "integer", - "minimum": 0 + "ok": { + "type": "boolean", + "enum": [true] } }, - "required": [ - "bucket", - "eventsIn", - "eventsOut", - "eventsFailed", - "instances" - ] + "required": ["ok"] }, - "UsageDestination": { + "SecretName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "CreateSecretRequest": { "type": "object", "properties": { "name": { - "type": "string" - }, - "count": { - "type": "integer", - "minimum": 0 - }, - "failed": { - "type": "integer", - "minimum": 0 - }, - "duration": { - "type": "number", - "minimum": 0 - }, - "dlqSize": { - "type": "integer", - "minimum": 0 + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Z_][A-Z0-9_]*$" }, - "dropped": { - "type": "integer", - "minimum": 0 + "value": { + "type": "string", + "minLength": 1, + "maxLength": 65536 } }, - "required": [ - "name", - "count", - "failed", - "duration", - "dlqSize", - "dropped" - ] + "required": ["name", "value"] }, - "CreateCustomDomainRequest": { + "UpdateSecretRequest": { "type": "object", "properties": { - "hostname": { + "value": { "type": "string", "minLength": 1, - "maxLength": 253 - }, - "deploymentId": { - "type": "string", - "minLength": 1 + "maxLength": 65536 } }, - "required": ["hostname"] + "required": ["value"] }, - "CustomDomain": { + "SecretSummary": { "type": "object", "properties": { "id": { "type": "string" }, - "deploymentId": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "status": { + "name": { "type": "string" }, - "scwResourceId": { - "type": ["string", "null"] - }, - "certStatus": { + "flowId": { "type": "string" }, - "verifiedAt": { - "type": ["string", "null"], - "format": "date-time" - }, "createdAt": { - "type": "string", + "type": ["string", "null"], "format": "date-time" }, "updatedAt": { - "type": "string", + "type": ["string", "null"], "format": "date-time" } }, - "required": [ - "id", - "deploymentId", - "hostname", - "kind", - "status", - "scwResourceId", - "certStatus", - "verifiedAt", - "createdAt", - "updatedAt" - ] + "required": ["id", "name", "flowId", "createdAt", "updatedAt"] }, - "ListCustomDomainsResponse": { + "SecretListResponse": { "type": "object", "properties": { - "domains": { + "secrets": { "type": "array", "items": { - "$ref": "#/components/schemas/CustomDomain" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time" + } + }, + "required": ["id", "name", "flowId", "createdAt", "updatedAt"] } } }, - "required": ["domains"] + "required": ["secrets"] }, - "DeployTokenStatusResponse": { - "anyOf": [ - { - "type": "object", - "properties": { - "hasToken": { - "type": "boolean", - "enum": [false] - } - }, - "required": ["hasToken"] + "FeedbackRequest": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 5000, + "example": "The MCP flow_bundle tool is great but slow on large configs." }, - { + "userId": { + "type": "string", + "format": "email", + "example": "alex@example.com" + }, + "projectId": { + "type": "string", + "maxLength": 255, + "example": "proj_abc123" + }, + "version": { + "type": "string", + "maxLength": 100, + "example": "0.4.2" + } + }, + "required": ["text"] + }, + "FeedbackResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [true] + }, + "id": { + "type": "string", + "example": "fb_abcdef1234567890" + } + }, + "required": ["ok", "id"] + }, + "StepExample": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "trigger": { "type": "object", "properties": { - "hasToken": { - "type": "boolean", - "enum": [true] - }, - "deploymentId": { - "type": "string" - }, - "status": { + "type": { "type": "string" }, - "healthy": { - "type": "boolean" - }, - "lastHeartbeatAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "instanceId": { - "type": ["string", "null"] - }, - "cliVersion": { - "type": ["string", "null"] - } - }, - "required": [ - "hasToken", - "deploymentId", - "status", - "healthy", - "lastHeartbeatAt", - "instanceId", - "cliVersion" - ] + "options": {} + } + }, + "mapping": {}, + "command": { + "type": "string" + }, + "in": { + "$ref": "#/components/schemas/StepExampleEvent" + }, + "out": { + "type": "array", + "items": { + "type": "array", + "items": {} + } } - ] + }, + "required": ["in"] }, - "CreateDeployTokenResponse": { + "StepExampleEvent": { "type": "object", "properties": { - "token": { + "entity": { "type": "string" }, - "deploymentId": { + "action": { "type": "string" }, - "projectId": { + "data": { + "type": "object", + "additionalProperties": {} + }, + "context": { + "type": "object", + "additionalProperties": {} + }, + "globals": { + "type": "object", + "additionalProperties": {} + }, + "custom": { + "type": "object", + "additionalProperties": {} + }, + "id": { "type": "string" }, - "flowId": { + "timestamp": { "type": "string" }, - "configName": { + "timing": { + "type": "object", + "additionalProperties": {} + }, + "user": { + "type": "object", + "additionalProperties": {} + }, + "version": { + "type": "string" + }, + "source": { + "type": "string" + }, + "trigger": { "type": "string" } - }, - "required": [ - "token", - "deploymentId", - "projectId", - "flowId", - "configName" - ] + } }, - "EntitlementsResponse": { + "CreateStepExampleRequest": { "type": "object", "properties": { - "planId": { + "title": { "type": "string" }, - "role": { + "description": { "type": "string" }, - "entitlements": { + "public": { + "type": "boolean" + }, + "trigger": { "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "number" - } - ] + "properties": { + "type": { + "type": "string" + }, + "options": {} + } + }, + "mapping": {}, + "command": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "event": { + "$ref": "#/components/schemas/StepExampleEvent" + }, + "out": { + "type": "array", + "items": { + "type": "array", + "items": {} } } }, - "required": ["planId", "role", "entitlements"] + "required": ["name", "event"] }, - "SetLlmConfigRequest": { - "oneOf": [ - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["clear"] - } - }, - "required": ["action"] + "EditStepExampleRequest": { + "type": "object", + "properties": { + "title": { + "type": "string" }, - { + "description": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "trigger": { "type": "object", "properties": { - "action": { - "type": "string", - "enum": ["set"] + "type": { + "type": "string" }, - "config": { - "oneOf": [ - { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["mistral"] - }, - "modelId": { - "type": "string", - "minLength": 1 - }, - "apiKey": { - "type": "string", - "minLength": 1 - } - }, - "required": ["provider", "modelId", "apiKey"] - }, - { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["anthropic"] - }, - "modelId": { - "type": "string", - "minLength": 1 - }, - "apiKey": { - "type": "string", - "minLength": 1 - } - }, - "required": ["provider", "modelId", "apiKey"] - }, - { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["openai"] - }, - "modelId": { - "type": "string", - "minLength": 1 - }, - "apiKey": { - "type": "string", - "minLength": 1 - } - }, - "required": ["provider", "modelId", "apiKey"] - }, - { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["google"] - }, - "modelId": { - "type": "string", - "minLength": 1 - }, - "apiKey": { - "type": "string", - "minLength": 1 - } - }, - "required": ["provider", "modelId", "apiKey"] - }, - { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": ["openai-compatible"] - }, - "modelId": { - "type": "string", - "minLength": 1 - }, - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseURL": { - "type": "string", - "format": "uri" - } - }, - "required": ["provider", "modelId", "apiKey", "baseURL"] - } - ] - } - }, - "required": ["action", "config"] - } - ] - }, - "SetLlmConfigResponse": { - "anyOf": [ - { - "type": "object", - "properties": { - "cleared": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["cleared"] + "options": {} + } }, - { - "type": "object", - "properties": { - "saved": { - "type": "boolean", - "enum": [true] - } - }, - "required": ["saved"] - } - ] - }, - "LlmConfigStatusResponse": { - "type": "object", - "properties": { - "provider": { + "mapping": {}, + "command": { "type": "string" }, - "source": { + "name": { "type": "string", - "enum": ["platform", "byok", "byom"] + "minLength": 1 + }, + "event": { + "$ref": "#/components/schemas/StepExampleEvent" + }, + "out": { + "type": "array", + "items": { + "type": "array", + "items": {} + } } }, - "required": ["provider", "source"] + "required": ["name"] }, - "ListChatSessionsResponse": { + "StepExamplesResponse": { "type": "object", "properties": { - "sessions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChatSessionSummary" + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/StepExample" } - }, - "total": { - "type": "integer", - "minimum": 0 } }, - "required": ["sessions", "total"] + "required": ["examples"] }, - "ChatSessionSummary": { + "ObserveStepExample": { "type": "object", "properties": { - "id": { + "in": {}, + "out": {}, + "mapping": {}, + "title": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastActiveAt": { - "type": "string", - "format": "date-time" - }, - "messageCount": { - "type": "integer", - "minimum": 0 - }, - "firstUserMessage": { + "description": { "type": "string" } - }, - "required": ["id", "createdAt", "lastActiveAt", "messageCount"] + } }, - "ChatSessionDetailResponse": { + "ObserveSaveExampleRequest": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "createdAt": { + "stepPath": { "type": "string", - "format": "date-time" + "minLength": 1 }, - "lastActiveAt": { + "scenario": { "type": "string", - "format": "date-time" + "minLength": 1 }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ChatSessionMessage" + "example": { + "$ref": "#/components/schemas/ObserveStepExample" + } + }, + "required": ["stepPath", "scenario", "example"] + }, + "SecretValuesResponse": { + "type": "object", + "properties": { + "values": { + "type": "object", + "additionalProperties": { + "type": "string" } } }, - "required": ["id", "createdAt", "lastActiveAt", "messages"] + "required": ["values"] }, - "ChatSessionMessage": { + "ServiceAccountSummary": { "type": "object", "properties": { - "seq": { - "type": "integer" + "id": { + "type": "string" }, - "role": { + "name": { "type": "string" }, - "content": {}, + "role": { + "type": "string", + "enum": ["member", "deployer", "viewer"] + }, + "email": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["seq", "role", "createdAt"] + "required": ["id", "name", "role", "email", "description", "createdAt"] }, - "ElicitRequest": { + "CreateServiceAccountRequest": { "type": "object", "properties": { - "sessionId": { + "name": { "type": "string", "minLength": 1, - "maxLength": 128 + "maxLength": 255 }, - "elicitationId": { + "role": { "type": "string", - "minLength": 1, - "maxLength": 128 + "enum": ["member", "deployer", "viewer"] }, - "result": { - "anyOf": [ - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["accept"] - }, - "content": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - }, - "required": ["action"] - }, - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["decline"] - } - }, - "required": ["action"] - }, - { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["cancel"] - } - }, - "required": ["action"] - } - ] - } - }, - "required": ["sessionId", "elicitationId", "result"] - }, - "ElicitResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [true] + "description": { + "type": "string", + "maxLength": 1000 } }, - "required": ["ok"] + "required": ["name", "role"] }, - "CreateMcpTokenRequest": { + "UpdateServiceAccountRequest": { "type": "object", "properties": { "name": { @@ -4738,14 +4775,17 @@ "minLength": 1, "maxLength": 255 }, - "ttlSeconds": { - "type": "integer", - "exclusiveMinimum": 0 + "description": { + "type": "string", + "maxLength": 1000 + }, + "role": { + "type": "string", + "enum": ["member", "deployer", "viewer"] } - }, - "required": ["name"] + } }, - "CreateMcpTokenResponse": { + "CreateServiceAccountResponse": { "type": "object", "properties": { "id": { @@ -4754,33 +4794,71 @@ "name": { "type": "string" }, + "role": { + "type": "string", + "enum": ["member", "deployer", "viewer"] + }, + "email": { + "type": "string" + }, "token": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" + "tokenId": { + "type": "string" }, - "expiresAt": { + "tokenPrefix": { + "type": "string" + }, + "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["id", "name", "token", "createdAt", "expiresAt"] + "required": [ + "id", + "name", + "role", + "email", + "token", + "tokenId", + "tokenPrefix", + "createdAt" + ] }, - "ListMcpTokensResponse": { + "ListServiceAccountsResponse": { "type": "object", "properties": { - "tokens": { + "serviceAccounts": { "type": "array", "items": { - "$ref": "#/components/schemas/McpTokenSummary" + "$ref": "#/components/schemas/ServiceAccountSummary" } + }, + "total": { + "type": "number" } }, - "required": ["tokens"] + "required": ["serviceAccounts", "total"] + }, + "CreateSaTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "expiresInDays": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 365 + } + }, + "required": ["name"] }, - "McpTokenSummary": { + "SaTokenSummary": { "type": "object", "properties": { "id": { @@ -4789,578 +4867,489 @@ "name": { "type": "string" }, - "audience": { + "prefix": { "type": "string" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, "lastUsedAt": { "type": ["string", "null"], - "format": "date-time" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, "expiresAt": { - "type": "string", - "format": "date-time" + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "revokedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, "required": [ "id", "name", - "audience", + "prefix", "createdAt", "lastUsedAt", - "expiresAt" + "expiresAt", + "revokedAt" ] }, - "PackageCatalogResponse": { + "CreateSaTokenResponse": { "type": "object", "properties": { - "catalog": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackageCatalogEntry" - } + "id": { + "type": "string" }, - "count": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["catalog", "count"] - }, - "PackageCatalogEntry": { - "type": "object", - "properties": { "name": { "type": "string" }, - "version": { + "token": { "type": "string" }, - "description": { + "prefix": { "type": "string" }, - "type": { - "type": "string" + "expiresAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "platform": { - "type": "array", - "items": { - "type": "string" - } + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["name", "version", "type", "platform"] + "required": ["id", "name", "token", "prefix", "expiresAt", "createdAt"] }, - "PackageSearchResponse": { + "ListSaTokensResponse": { "type": "object", "properties": { - "packages": { + "tokens": { "type": "array", "items": { - "$ref": "#/components/schemas/PackageSearchHit" + "$ref": "#/components/schemas/SaTokenSummary" } }, - "count": { - "type": "integer", - "minimum": 0 + "total": { + "type": "number" } }, - "required": ["packages", "count"] + "required": ["tokens", "total"] }, - "PackageSearchHit": { + "Invitation": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "version": { - "type": "string" + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" }, - "description": { - "type": "string" - } - }, - "required": ["name", "version", "description"] - }, - "PackageSearchLogRequest": { - "type": "object", - "properties": { - "query": { + "role": { "type": "string", - "minLength": 1, - "maxLength": 214 + "enum": ["admin", "member", "deployer", "viewer"], + "default": "member", + "example": "member" }, - "result": { + "status": { "type": "string", - "enum": ["hit", "miss"] + "enum": ["pending", "accepted", "declined", "expired", "cancelled"] }, - "platform": { + "invitedBy": { + "type": ["string", "null"] + }, + "expiresAt": { "type": "string", - "enum": ["web", "server"] + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "projectId": { + "createdAt": { "type": "string", - "maxLength": 64 + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["query", "result"] + "required": [ + "id", + "email", + "status", + "invitedBy", + "expiresAt", + "createdAt" + ] }, - "ListRunnersResponse": { + "CreateInvitationRequest": { "type": "object", "properties": { - "runners": { - "type": "array", - "items": {} + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" }, - "total": { - "type": "integer", - "minimum": 0 + "role": { + "type": "string", + "enum": ["admin", "member", "deployer", "viewer"], + "default": "member", + "example": "member" } }, - "required": ["runners", "total"] + "required": ["email"] }, - "RunnerHeartbeatResponse": { + "CreateInvitationResponse": { "type": "object", "properties": { "id": { "type": "string" }, - "instanceId": { - "type": "string" + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" }, - "deploymentId": { - "type": "string" - } - }, - "required": ["id", "instanceId", "deploymentId"] - }, - "ObserveTimingRequest": { - "type": "object", - "properties": { - "connectId": { + "role": { "type": "string", - "minLength": 1 + "enum": ["admin", "member", "deployer", "viewer"], + "default": "member", + "example": "member" }, - "ticketMs": { - "type": "number", - "minimum": 0 + "status": { + "type": "string", + "enum": ["pending"] }, - "sseMs": { - "type": "number", - "minimum": 0 + "expiresAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "totalMs": { - "type": "number", - "minimum": 0 + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" } }, - "required": ["connectId", "ticketMs", "sseMs", "totalMs"] + "required": ["id", "email", "status", "expiresAt", "createdAt"] }, - "MagicLinkResponse": { + "ListInvitationsResponse": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true + "invitations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invitation" + } }, - "message": { - "type": "string", - "example": "Magic link sent" + "total": { + "type": "number" } }, - "required": ["success", "message"] + "required": ["invitations", "total"] }, - "MagicLinkRequest": { + "InvitationPreview": { "type": "object", "properties": { + "projectName": { + "type": "string" + }, "email": { "type": "string", "format": "email", "example": "user@example.com" }, - "redirect_to": { + "role": { "type": "string", - "example": "/dashboard" - } - }, - "required": ["email"] - }, - "VerifyResponse": { - "oneOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["ok"] - }, - "redirectTo": { - "type": "string", - "example": "/" - } - }, - "required": ["status", "redirectTo"] - }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["confirm_required"] - }, - "email": { - "type": "string", - "example": "user@example.com" - } - }, - "required": ["status", "email"] + "enum": ["admin", "member", "deployer", "viewer"], + "default": "member", + "example": "member" }, - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["expired", "used", "invalid", "malformed"] - } - }, - "required": ["status"] - } - ] - }, - "VerifyRequest": { - "type": "object", - "properties": { - "token": { + "status": { "type": "string", - "minLength": 1 + "enum": ["pending", "accepted", "declined", "expired", "cancelled"] }, - "redirect_to": { + "expiresAt": { "type": "string", - "example": "/dashboard" + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" }, - "confirm": { - "type": "boolean" + "invitedByEmail": { + "type": "string", + "format": "email", + "example": "user@example.com" } }, - "required": ["token"] + "required": [ + "projectName", + "email", + "status", + "expiresAt", + "invitedByEmail" + ] }, - "WhoamiResponse": { + "AcceptInvitationResponse": { "type": "object", "properties": { - "userId": { - "type": "string", - "example": "user_a1b2c3d4" + "projectId": { + "type": "string" }, - "email": { + "projectName": { + "type": "string" + }, + "role": { "type": "string", - "format": "email", - "example": "user@example.com" + "enum": ["admin", "member", "deployer", "viewer"], + "default": "member", + "example": "member" }, - "projectId": { - "type": ["string", "null"], - "example": null + "alreadyMember": { + "type": "boolean" } }, - "required": ["userId", "email", "projectId"] + "required": ["projectId", "projectName", "alreadyMember"] }, - "ListSessionsResponse": { + "TelemetryEvent": { "type": "object", "properties": { - "sessions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "lastTouchedAt": { - "type": "string", - "format": "date-time" - }, - "isCurrent": { - "type": "boolean" - } - }, - "required": [ - "id", - "createdAt", - "expiresAt", - "lastTouchedAt", - "isCurrent" - ] - } - } - }, - "required": ["sessions"] - }, - "DeviceCodeResponse": { - "type": "object", - "properties": { - "deviceCode": { + "id": { "type": "string" }, - "userCode": { + "name": { "type": "string" }, - "expiresIn": { - "type": "number" - }, - "interval": { - "type": "number" - } - }, - "required": ["deviceCode", "userCode", "expiresIn", "interval"] - }, - "ApproveDeviceResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": ["success"] - }, - "ApproveDeviceRequest": { - "type": "object", - "properties": { - "userCode": { - "type": "string", - "minLength": 1 - } - }, - "required": ["userCode"] - }, - "DeviceTokenResponse": { - "type": "object", - "properties": { - "token": { + "entity": { "type": "string" }, - "email": { + "action": { "type": "string" }, - "userId": { - "type": "string" - } - }, - "required": ["token", "email", "userId"] - }, - "DeviceTokenRequest": { - "type": "object", - "properties": { - "deviceCode": { - "type": "string", - "minLength": 1 + "data": { + "type": "object", + "additionalProperties": {} }, - "hostname": { - "type": "string" - } - }, - "required": ["deviceCode"] - }, - "ListProjectsResponse": { - "type": "object", - "properties": { - "projects": { + "context": { + "type": "object", + "additionalProperties": {} + }, + "globals": { + "type": "object", + "additionalProperties": {} + }, + "custom": { + "type": "object", + "additionalProperties": {} + }, + "user": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "session": { + "type": "string" + }, + "os": { + "type": "string" + }, + "osVersion": { + "type": "string" + }, + "node": { + "type": "string" + }, + "language": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "device", + "os", + "osVersion", + "node", + "language", + "timezone" + ], + "additionalProperties": {} + }, + "nested": { "type": "array", - "items": { - "$ref": "#/components/schemas/Project" + "items": {} + }, + "consent": { + "type": "object", + "additionalProperties": { + "type": "boolean" } }, - "total": { + "trigger": { + "type": "string" + }, + "timestamp": { "type": "number" }, - "nextCursor": { - "type": ["string", "null"] + "timing": { + "type": "number" + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["cli", "mcp"] + }, + "platform": { + "type": "string" + }, + "release": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "version": { + "type": "string" + }, + "schema": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": {} } }, - "required": ["projects", "total", "nextCursor"] + "required": [ + "id", + "name", + "entity", + "action", + "data", + "context", + "globals", + "custom", + "user", + "nested", + "consent", + "trigger", + "timestamp", + "timing", + "source" + ] }, - "CreateProjectRequest": { + "UpsertBillingDetailsRequest": { "type": "object", "properties": { - "name": { + "companyName": { "type": "string", "minLength": 1, "maxLength": 255 - } - }, - "required": ["name"] - }, - "ProjectDetailResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" }, - "name": { - "type": "string" + "address": { + "type": "string", + "minLength": 1, + "maxLength": 255 }, - "siteUrl": { - "type": ["string", "null"], - "format": "uri", - "example": "https://example.com" + "address2": { + "type": "string", + "maxLength": 255 }, - "role": { + "postalCode": { "type": "string", - "enum": ["owner", "admin", "member", "deployer", "viewer"] - } - }, - "required": ["id", "name", "role"] - }, - "UpdateProjectRequest": { - "type": "object", - "properties": { - "name": { + "minLength": 1, + "maxLength": 20 + }, + "city": { "type": "string", "minLength": 1, "maxLength": 255 }, - "siteUrl": { - "type": ["string", "null"], - "maxLength": 2048, - "format": "uri", - "example": "https://example.com" + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2 + }, + "vatId": { + "type": "string", + "maxLength": 50 + }, + "invoiceEmail": { + "type": "string", + "format": "email" + }, + "contactName": { + "type": "string", + "maxLength": 255 } - } + }, + "required": [ + "companyName", + "address", + "postalCode", + "city", + "country", + "invoiceEmail" + ] }, - "ListMembersResponse": { + "BillingDetailsResponse": { "type": "object", "properties": { - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Member" - } + "id": { + "type": "string" }, - "total": { - "type": "number" - } - }, - "required": ["members", "total"] - }, - "AddMemberRequest": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "example": "user@example.com" + "projectId": { + "type": "string" }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member", "deployer", "viewer"], - "default": "member" - } - }, - "required": ["email"] - }, - "UpdateMemberRequest": { - "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["owner", "admin", "member", "deployer", "viewer"] - } - }, - "required": ["role"] - }, - "ListFlowsResponse": { - "type": "object", - "properties": { - "flows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSummary" - } + "companyName": { + "type": "string" }, - "total": { - "type": "integer", - "minimum": 0 + "address": { + "type": "string" }, - "nextCursor": { + "address2": { "type": ["string", "null"] - } - }, - "required": ["flows", "total", "nextCursor"] - }, - "CreateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "example": "my-website-flow" }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - } - }, - "required": ["name"] - }, - "UpdateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "example": "my-website-flow" + "postalCode": { + "type": "string" }, - "config": { - "$ref": "#/components/schemas/FlowConfig" - } - } - }, - "DuplicateFlowRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "example": "my-website-flow" - } - } - }, - "DeploymentResponse": { - "type": ["object", "null"], - "properties": { - "id": { + "city": { "type": "string" }, - "flowId": { + "country": { "type": "string" }, - "type": { - "type": "string", - "enum": ["web", "server"] + "vatId": { + "type": ["string", "null"] }, - "status": { + "invoiceEmail": { "type": "string" }, - "containerUrl": { + "contactName": { "type": ["string", "null"] }, - "publicUrl": { - "type": ["string", "null"] + "taxTreatment": { + "type": "string", + "enum": ["reverse_charge", "domestic", "export", "eu_standard"] }, - "errorMessage": { + "viesStatus": { + "type": "string", + "enum": ["verified", "invalid", "unavailable", "not_checked"] + }, + "viesCompanyName": { "type": ["string", "null"] }, "createdAt": { @@ -5374,646 +5363,4218 @@ }, "required": [ "id", - "flowId", - "type", - "status", - "containerUrl", - "errorMessage", + "projectId", + "companyName", + "address", + "address2", + "postalCode", + "city", + "country", + "vatId", + "invoiceEmail", + "contactName", + "taxTreatment", + "viesStatus", + "viesCompanyName", "createdAt", "updatedAt" ] }, - "ListSettingsResponse": { + "DeployedContentResponse": { "type": "object", "properties": { - "settings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FlowSettingsSummary" - } - } - }, - "required": ["settings"] - }, - "SettingsDeploymentResponse": { - "type": ["object", "null"], - "properties": { - "id": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "settingsId": { - "type": "string" + "deploymentId": { + "type": ["string", "null"] }, - "type": { - "type": "string", - "enum": ["web", "server"] + "versionNumber": { + "type": ["integer", "null"], + "exclusiveMinimum": 0 }, "status": { - "type": "string" - }, - "containerUrl": { - "type": ["string", "null"] - }, - "publicUrl": { "type": ["string", "null"] }, - "errorMessage": { + "flowSettingsName": { "type": ["string", "null"] }, - "createdAt": { - "type": "string", + "publishedAt": { + "type": ["string", "null"], "format": "date-time" }, - "updatedAt": { - "type": "string", - "format": "date-time" - } + "content": {} }, "required": [ - "id", - "flowId", - "settingsId", - "type", + "deploymentId", + "versionNumber", "status", - "containerUrl", - "publicUrl", - "errorMessage", - "createdAt", - "updatedAt" + "flowSettingsName", + "publishedAt" ] }, - "SettingsDeploymentDetailResponse": { + "ListHeartbeatsResponse": { "type": "object", "properties": { - "id": { - "type": "string" + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HeartbeatRecord" + } }, - "flowId": { - "type": "string" + "total": { + "type": "integer", + "minimum": 0 }, - "settingsId": { - "type": "string" + "limit": { + "type": "integer", + "exclusiveMinimum": 0 }, - "status": { + "offset": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["records", "total", "limit", "offset"] + }, + "HeartbeatRecord": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "type": { - "type": "string", - "enum": ["web", "server"] - }, - "containerUrl": { + "instanceId": { "type": ["string", "null"] }, - "publicUrl": { + "cliVersion": { "type": ["string", "null"] }, - "errorMessage": { + "configVersion": { + "type": ["integer", "null"] + }, + "mode": { "type": ["string", "null"] }, - "createdAt": { - "type": "string", - "format": "date-time" + "uptime": { + "type": ["integer", "null"] }, - "updatedAt": { - "type": "string", - "format": "date-time" + "eventsIn": { + "type": ["integer", "null"], + "minimum": 0 + }, + "eventsOut": { + "type": ["integer", "null"], + "minimum": 0 + }, + "eventsFailed": { + "type": ["integer", "null"], + "minimum": 0 + }, + "perDestinationBreakdown": {}, + "receivedAt": { + "type": "string", + "format": "date-time" } }, "required": [ "id", - "flowId", - "settingsId", - "status", - "type", - "containerUrl", - "publicUrl", - "errorMessage", - "createdAt", - "updatedAt" + "instanceId", + "cliVersion", + "configVersion", + "mode", + "uptime", + "eventsIn", + "eventsOut", + "eventsFailed", + "receivedAt" ] }, - "FlowJourneysResponse": { + "RotateIngestTokenResponse": { "type": "object", "properties": { - "sessionId": { - "type": ["string", "null"], - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "ingestToken": { + "type": "string" + } + }, + "required": ["ingestToken"] + }, + "DeploymentUsageResponse": { + "type": "object", + "properties": { + "totalEventsIn": { + "type": "integer", + "minimum": 0 }, - "flowId": { + "totalEventsOut": { + "type": "integer", + "minimum": 0 + }, + "totalEventsFailed": { + "type": "integer", + "minimum": 0 + }, + "totalInstances": { + "type": "integer", + "minimum": 0 + }, + "heartbeatCount": { + "type": "integer", + "minimum": 0 + }, + "from": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "format": "date-time" }, - "assembledAt": { + "to": { "type": "string", "format": "date-time" }, - "journeys": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "averageThroughputPerHour": { + "type": "integer", + "minimum": 0 }, - "gaps": { + "period": { + "type": "string" + }, + "buckets": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/UsageBucket" } }, - "unattributed": { + "destinations": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "$ref": "#/components/schemas/UsageDestination" } } }, - "required": ["sessionId", "flowId", "assembledAt", "journeys", "gaps"] + "required": [ + "totalEventsIn", + "totalEventsOut", + "totalEventsFailed", + "totalInstances", + "heartbeatCount", + "from", + "to", + "averageThroughputPerHour", + "period", + "buckets" + ] }, - "ListVersionsResponse": { + "UsageBucket": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Version" - } - }, - "flowId": { + "bucket": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "format": "date-time" }, - "total": { + "eventsIn": { "type": "integer", "minimum": 0 }, - "limit": { + "eventsOut": { "type": "integer", - "exclusiveMinimum": 0 + "minimum": 0 }, - "offset": { + "eventsFailed": { "type": "integer", "minimum": 0 }, - "hasMore": { - "type": "boolean" + "instances": { + "type": "integer", + "minimum": 0 } }, - "required": ["data", "flowId", "total", "limit", "offset", "hasMore"] + "required": [ + "bucket", + "eventsIn", + "eventsOut", + "eventsFailed", + "instances" + ] }, - "GetVersionResponse": { + "UsageDestination": { "type": "object", "properties": { - "version": { + "name": { + "type": "string" + }, + "count": { "type": "integer", - "exclusiveMinimum": 0, - "example": 1 + "minimum": 0 }, - "content": { - "$ref": "#/components/schemas/FlowConfig" + "failed": { + "type": "integer", + "minimum": 0 }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "duration": { + "type": "number", + "minimum": 0 }, - "createdBy": { - "type": "string", - "enum": ["user", "auto_save", "restore", "deploy", "preview"] + "dlqSize": { + "type": "integer", + "minimum": 0 + }, + "dropped": { + "type": "integer", + "minimum": 0 } }, - "required": ["version", "content", "createdAt", "createdBy"] + "required": [ + "name", + "count", + "failed", + "duration", + "dlqSize", + "dropped" + ] }, - "ListApiTokensResponse": { + "CreateCustomDomainRequest": { "type": "object", "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ApiTokenSummary" - } + "hostname": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "deploymentId": { + "type": "string", + "minLength": 1 } }, - "required": ["tokens"] + "required": ["hostname"] }, - "CreateApiTokenResponse": { + "CustomDomain": { "type": "object", "properties": { "id": { - "type": "string", - "example": "tok_a1b2c3d4" + "type": "string" }, - "name": { - "type": "string", - "example": "CI Pipeline" + "deploymentId": { + "type": "string" }, - "token": { - "type": "string", - "example": "sk-walkeros-abcd1234..." + "hostname": { + "type": "string" }, - "prefix": { - "type": "string", - "example": "sk-walkeros-abcd" + "kind": { + "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "status": { + "type": "string" }, - "expiresAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-01-26T14:30:00.000Z" + "scwResourceId": { + "type": ["string", "null"] }, - "projectId": { + "certStatus": { + "type": "string" + }, + "verifiedAt": { "type": ["string", "null"], - "example": null + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } }, "required": [ "id", - "name", - "token", - "prefix", + "deploymentId", + "hostname", + "kind", + "status", + "scwResourceId", + "certStatus", + "verifiedAt", "createdAt", - "expiresAt", - "projectId" + "updatedAt" ] }, - "CreateApiTokenRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "example": "CI Pipeline" - }, - "expiresInDays": { - "type": ["integer", "null"], - "exclusiveMinimum": 0, - "maximum": 365, - "example": 90 - } - }, - "required": ["name"] - }, - "BundleResponse": { + "ListCustomDomainsResponse": { "type": "object", "properties": { - "bundleId": { - "type": "string" - }, - "cached": { - "type": "boolean" + "domains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomDomain" + } } }, - "required": ["bundleId", "cached"] + "required": ["domains"] }, - "SimulateResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + "DeployTokenStatusResponse": { + "anyOf": [ + { + "type": "object", + "properties": { + "hasToken": { + "type": "boolean", + "enum": [false] + } + }, + "required": ["hasToken"] }, - "result": { + { "type": "object", "properties": { - "step": { - "type": "string", - "enum": ["source", "transformer", "destination"] + "hasToken": { + "type": "boolean", + "enum": [true] }, - "name": { + "deploymentId": { "type": "string" }, - "events": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "status": { + "type": "string" }, - "calls": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fn": { - "type": "string" - }, - "args": { - "type": "array", - "items": {} - }, - "ts": { - "type": "number" - } - }, - "required": ["fn", "args", "ts"] - } + "healthy": { + "type": "boolean" }, - "duration": { - "type": "number" + "lastHeartbeatAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "instanceId": { + "type": ["string", "null"] + }, + "cliVersion": { + "type": ["string", "null"] } }, - "required": ["step", "name", "events", "calls", "duration"] + "required": [ + "hasToken", + "deploymentId", + "status", + "healthy", + "lastHeartbeatAt", + "instanceId", + "cliVersion" + ] } - }, - "required": ["success"] + ] }, - "SimulateRequest": { + "CreateDeployTokenResponse": { "type": "object", "properties": { - "bundleId": { - "type": "string", - "pattern": "^[a-f0-9]{8,64}$" + "token": { + "type": "string" }, - "config": { - "type": "object", - "additionalProperties": {} + "deploymentId": { + "type": "string" }, - "event": { - "type": "object", - "additionalProperties": {} + "projectId": { + "type": "string" }, - "step": { - "type": "string", - "pattern": "^(source|transformer|destination)\\..+$" - } - }, - "required": ["bundleId", "config", "event", "step"] - }, - "RegisterRuntimeRequest": { - "type": "object", - "properties": { "flowId": { "type": "string" }, - "bundlePath": { + "configName": { "type": "string" } }, - "required": ["flowId", "bundlePath"] + "required": [ + "token", + "deploymentId", + "projectId", + "flowId", + "configName" + ] }, - "ValidateTicketResponse": { + "EntitlementsResponse": { "type": "object", "properties": { - "userId": { + "planId": { "type": "string" }, - "projectId": { + "role": { "type": "string" }, - "replay": { + "entitlements": { "type": "object", - "properties": { - "size": { - "type": "number" - }, - "ttlMs": { - "type": "number" - } - }, - "required": ["size", "ttlMs"] - }, - "scope": { - "anyOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": ["session"] - }, - "sessionId": { - "type": "string" - } + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" }, - "required": ["kind", "sessionId"] - }, - { - "type": "null" - } - ] - } - }, - "required": ["userId", "projectId", "replay", "scope"] - }, - "ValidateTicketRequest": { - "type": "object", - "properties": { - "ticket": { - "type": "string", - "minLength": 1 - } - }, - "required": ["ticket"] - }, - "HealthResponse": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "ok" - }, - "appVersion": { - "type": "string", - "example": "a1b2c3d", - "description": "Build identity of the running app (git short hash injected at build time)." - }, - "contractVersion": { - "type": "string", - "example": "1.0.0", - "description": "Semver of the API contract the server implements." - }, - "contractHash": { - "type": "string", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "description": "Deterministic sha256 of the OpenAPI contract content." - } - }, - "required": ["status", "appVersion", "contractVersion", "contractHash"] - }, - "DeclineInvitationResponse": { - "type": "object", - "properties": { - "message": { - "type": "string" + { + "type": "number" + } + ] + } } }, - "required": ["message"] + "required": ["planId", "role", "entitlements"] }, - "HeartbeatRequest": { - "type": "object", - "properties": { - "instanceId": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "example": "a1b2c3d4e5f6" + "SetLlmConfigRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["clear"] + } + }, + "required": ["action"] }, - "flowId": { + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["set"] + }, + "config": { + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["mistral"] + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider", "modelId", "apiKey"] + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["anthropic"] + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider", "modelId", "apiKey"] + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["openai"] + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider", "modelId", "apiKey"] + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["google"] + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider", "modelId", "apiKey"] + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["openai-compatible"] + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseURL": { + "type": "string", + "format": "uri" + } + }, + "required": ["provider", "modelId", "apiKey", "baseURL"] + } + ] + } + }, + "required": ["action", "config"] + } + ] + }, + "SetLlmConfigResponse": { + "anyOf": [ + { + "type": "object", + "properties": { + "cleared": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["cleared"] + }, + { + "type": "object", + "properties": { + "saved": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["saved"] + } + ] + }, + "LlmConfigStatusResponse": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "source": { "type": "string", - "example": "flow_abc123" + "enum": ["platform", "byok", "byom"] + } + }, + "required": ["provider", "source"] + }, + "ListChatSessionsResponse": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatSessionSummary" + } }, - "configVersion": { + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessions", "total"] + }, + "ChatSessionSummary": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "mode": { + "createdAt": { "type": "string", - "enum": ["local", "collect", "serve"] + "format": "date-time" }, - "cliVersion": { + "lastActiveAt": { "type": "string", - "example": "1.3.0" + "format": "date-time" }, - "uptime": { + "messageCount": { "type": "integer", "minimum": 0 }, - "metadata": { - "type": "object", - "additionalProperties": {} + "firstUserMessage": { + "type": "string" + } + }, + "required": ["id", "createdAt", "lastActiveAt", "messageCount"] + }, + "ChatSessionDetailResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatSessionMessage" + } + } + }, + "required": ["id", "createdAt", "lastActiveAt", "messages"] + }, + "ChatSessionMessage": { + "type": "object", + "properties": { + "seq": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["seq", "role", "createdAt"] + }, + "ElicitRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "elicitationId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["accept"] + }, + "content": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["decline"] + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["cancel"] + } + }, + "required": ["action"] + } + ] + } + }, + "required": ["sessionId", "elicitationId", "result"] + }, + "ElicitResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["ok"] + }, + "PackageCatalogResponse": { + "type": "object", + "properties": { + "catalog": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageCatalogEntry" + } + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["catalog", "count"] + }, + "PackageCatalogEntry": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "platform": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "version", "type", "platform"] + }, + "PackageSearchResponse": { + "type": "object", + "properties": { + "packages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageSearchHit" + } + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["packages", "count"] + }, + "PackageSearchHit": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name", "version", "description"] + }, + "PackageSearchLogRequest": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 214 + }, + "result": { + "type": "string", + "enum": ["hit", "miss"] + }, + "platform": { + "type": "string", + "enum": ["web", "server"] + }, + "projectId": { + "type": "string", + "maxLength": 64 + } + }, + "required": ["query", "result"] + }, + "ListRunnersResponse": { + "type": "object", + "properties": { + "runners": { + "type": "array", + "items": {} + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["runners", "total"] + }, + "RunnerHeartbeatResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "instanceId": { + "type": "string" + }, + "deploymentId": { + "type": "string" + } + }, + "required": ["id", "instanceId", "deploymentId"] + }, + "ObserveTimingRequest": { + "type": "object", + "properties": { + "connectId": { + "type": "string", + "minLength": 1 + }, + "ticketMs": { + "type": "number", + "minimum": 0 + }, + "sseMs": { + "type": "number", + "minimum": 0 + }, + "totalMs": { + "type": "number", + "minimum": 0 + } + }, + "required": ["connectId", "ticketMs", "sseMs", "totalMs"] + }, + "MagicLinkResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Magic link sent" + } + }, + "required": ["success", "message"] + }, + "MagicLinkRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" + }, + "redirect_to": { + "type": "string", + "example": "/dashboard" + } + }, + "required": ["email"] + }, + "VerifyResponse": { + "oneOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["ok"] + }, + "redirectTo": { + "type": "string", + "example": "/" + } + }, + "required": ["status", "redirectTo"] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["confirm_required"] + }, + "email": { + "type": "string", + "example": "user@example.com" + } + }, + "required": ["status", "email"] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["expired", "used", "invalid", "malformed"] + } + }, + "required": ["status"] + } + ] + }, + "VerifyRequest": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "redirect_to": { + "type": "string", + "example": "/dashboard" + }, + "confirm": { + "type": "boolean" + } + }, + "required": ["token"] + }, + "WhoamiResponse": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "example": "user_a1b2c3d4" + }, + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" + }, + "projectId": { + "type": ["string", "null"], + "example": null + } + }, + "required": ["userId", "email", "projectId"] + }, + "ListSessionsResponse": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "lastTouchedAt": { + "type": "string", + "format": "date-time" + }, + "isCurrent": { + "type": "boolean" + } + }, + "required": [ + "id", + "createdAt", + "expiresAt", + "lastTouchedAt", + "isCurrent" + ] + } + } + }, + "required": ["sessions"] + }, + "ListProjectsResponse": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + }, + "total": { + "type": "number" + }, + "nextCursor": { + "type": ["string", "null"] + } + }, + "required": ["projects", "total", "nextCursor"] + }, + "CreateProjectRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["name"] + }, + "ProjectDetailResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "name": { + "type": "string" + }, + "siteUrl": { + "type": ["string", "null"], + "format": "uri", + "example": "https://example.com" + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member", "deployer", "viewer"] + } + }, + "required": ["id", "name", "role"] + }, + "UpdateProjectRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "siteUrl": { + "type": ["string", "null"], + "maxLength": 2048, + "format": "uri", + "example": "https://example.com" + } + } + }, + "ListMembersResponse": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" + } + }, + "total": { + "type": "number" + } + }, + "required": ["members", "total"] + }, + "AddMemberRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member", "deployer", "viewer"], + "default": "member" + } + }, + "required": ["email"] + }, + "UpdateMemberRequest": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["owner", "admin", "member", "deployer", "viewer"] + } + }, + "required": ["role"] + }, + "ListFlowsResponse": { + "type": "object", + "properties": { + "flows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowSummary" + } + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "nextCursor": { + "type": ["string", "null"] + } + }, + "required": ["flows", "total", "nextCursor"] + }, + "CreateFlowRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "my-website-flow" + }, + "config": { + "$ref": "#/components/schemas/FlowConfig" + } + }, + "required": ["name"] + }, + "UpdateFlowRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "my-website-flow" + }, + "config": { + "$ref": "#/components/schemas/FlowConfig" + } + } + }, + "DuplicateFlowRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "my-website-flow" + } + } + }, + "DeploymentResponse": { + "type": ["object", "null"], + "properties": { + "id": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["web", "server"] + }, + "status": { + "type": "string" + }, + "containerUrl": { + "type": ["string", "null"] + }, + "publicUrl": { + "type": ["string", "null"] + }, + "errorMessage": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "flowId", + "type", + "status", + "containerUrl", + "errorMessage", + "createdAt", + "updatedAt" + ] + }, + "ListSettingsResponse": { + "type": "object", + "properties": { + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowSettingsSummary" + } + } + }, + "required": ["settings"] + }, + "SettingsDeploymentResponse": { + "type": ["object", "null"], + "properties": { + "id": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "settingsId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["web", "server"] + }, + "status": { + "type": "string" + }, + "containerUrl": { + "type": ["string", "null"] + }, + "publicUrl": { + "type": ["string", "null"] + }, + "errorMessage": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "flowId", + "settingsId", + "type", + "status", + "containerUrl", + "publicUrl", + "errorMessage", + "createdAt", + "updatedAt" + ] + }, + "SettingsDeploymentDetailResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "settingsId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["web", "server"] + }, + "containerUrl": { + "type": ["string", "null"] + }, + "publicUrl": { + "type": ["string", "null"] + }, + "errorMessage": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "flowId", + "settingsId", + "status", + "type", + "containerUrl", + "publicUrl", + "errorMessage", + "createdAt", + "updatedAt" + ] + }, + "FlowJourneysResponse": { + "type": "object", + "properties": { + "sessionId": { + "type": ["string", "null"], + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" + }, + "flowId": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "assembledAt": { + "type": "string", + "format": "date-time" + }, + "journeys": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "unattributed": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + } + }, + "required": ["sessionId", "flowId", "assembledAt", "journeys", "gaps"] + }, + "ListVersionsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Version" + } + }, + "flowId": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["data", "flowId", "total", "limit", "offset", "hasMore"] + }, + "GetVersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "example": 1 + }, + "content": { + "$ref": "#/components/schemas/FlowConfig" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "createdBy": { + "type": "string", + "enum": ["user", "auto_save", "restore", "deploy", "preview"] + } + }, + "required": ["version", "content", "createdAt", "createdBy"] + }, + "ListAutomationTokensResponse": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationTokenSummary" + } + } + }, + "required": ["tokens"] + }, + "CreateAutomationTokenResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "tok_a1b2c3d4" + }, + "name": { + "type": "string", + "example": "CI Pipeline" + }, + "token": { + "type": "string", + "example": "wos_pat_a1b2c3d4..." + }, + "tokenPrefix": { + "type": "string", + "example": "wos_pat_a1b2" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["read", "write"] + }, + "audience": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["api", "mcp"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + } + }, + "required": [ + "id", + "name", + "token", + "tokenPrefix", + "scope", + "audience", + "createdAt", + "expiresAt" + ] + }, + "CreateAutomationTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "example": "CI Pipeline" + }, + "scope": { + "type": "string", + "enum": ["read", "read write"], + "example": "read write" + }, + "expiresInDays": { + "anyOf": [ + { + "type": "number", + "enum": [30] + }, + { + "type": "number", + "enum": [90] + }, + { + "type": "number", + "enum": [180] + }, + { + "type": "number", + "enum": [365] + } + ], + "example": 90 + } + }, + "required": ["name", "scope", "expiresInDays"] + }, + "BundleResponse": { + "type": "object", + "properties": { + "bundleId": { + "type": "string" + }, + "cached": { + "type": "boolean" + } + }, + "required": ["bundleId", "cached"] + }, + "SimulateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "result": { + "type": "object", + "properties": { + "step": { + "type": "string", + "enum": ["source", "transformer", "destination"] + }, + "name": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "calls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fn": { + "type": "string" + }, + "args": { + "type": "array", + "items": {} + }, + "ts": { + "type": "number" + } + }, + "required": ["fn", "args", "ts"] + } + }, + "duration": { + "type": "number" + } + }, + "required": ["step", "name", "events", "calls", "duration"] + } + }, + "required": ["success"] + }, + "SimulateRequest": { + "type": "object", + "properties": { + "bundleId": { + "type": "string", + "pattern": "^[a-f0-9]{8,64}$" + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "event": { + "type": "object", + "additionalProperties": {} + }, + "step": { + "type": "string", + "pattern": "^(source|transformer|destination)\\..+$" + } + }, + "required": ["bundleId", "config", "event", "step"] + }, + "RegisterRuntimeRequest": { + "type": "object", + "properties": { + "flowId": { + "type": "string" + }, + "bundlePath": { + "type": "string" + } + }, + "required": ["flowId", "bundlePath"] + }, + "ValidateTicketResponse": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "replay": { + "type": "object", + "properties": { + "size": { + "type": "number" + }, + "ttlMs": { + "type": "number" + } + }, + "required": ["size", "ttlMs"] + }, + "scope": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["session"] + }, + "sessionId": { + "type": "string" + } + }, + "required": ["kind", "sessionId"] + }, + { + "type": "null" + } + ] + } + }, + "required": ["userId", "projectId", "replay", "scope"] + }, + "ValidateTicketRequest": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "minLength": 1 + } + }, + "required": ["ticket"] + }, + "HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "appVersion": { + "type": "string", + "example": "a1b2c3d", + "description": "Build identity of the running app (git short hash injected at build time)." + }, + "contractVersion": { + "type": "string", + "example": "1.0.0", + "description": "Semver of the API contract the server implements." + }, + "contractHash": { + "type": "string", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "description": "Deterministic sha256 of the OpenAPI contract content." + } + }, + "required": ["status", "appVersion", "contractVersion", "contractHash"] + }, + "DeclineInvitationResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + }, + "ScreenshotUploadResponse": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "pattern": "^fas_[A-Za-z0-9_-]{21}$", + "example": "fas_V1StGXR8Z5jdHi6BmyT7K" + }, + "reused": { + "type": "boolean" + } + }, + "required": ["assetId", "reused"] + }, + "FrameScreenshotMeta": { + "type": "object", + "properties": { + "capturedAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "size": { + "$ref": "#/components/schemas/PlanSize" + }, + "dpr": { + "type": "number", + "exclusiveMinimum": 0 + }, + "capturedRect": { + "$ref": "#/components/schemas/PlanRect" + } + }, + "required": ["capturedAt", "size", "dpr", "capturedRect"] + }, + "HeartbeatRequest": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "a1b2c3d4e5f6" + }, + "flowId": { + "type": "string", + "example": "flow_abc123" + }, + "configVersion": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["local", "collect", "serve"] + }, + "cliVersion": { + "type": "string", + "example": "1.3.0" + }, + "uptime": { + "type": "integer", + "minimum": 0 + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "deploymentId": { + "type": "string", + "example": "dpl_abc123" + }, + "counters": { + "type": "object", + "properties": { + "eventsIn": { + "type": "integer", + "minimum": 0 + }, + "eventsOut": { + "type": "integer", + "minimum": 0 + }, + "eventsFailed": { + "type": "integer", + "minimum": 0 + }, + "destinations": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "duration": { + "type": "number", + "minimum": 0 + }, + "dlqSize": { + "type": "integer", + "minimum": 0 + }, + "dropped": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["count", "failed", "duration"] + } + } + }, + "required": [ + "eventsIn", + "eventsOut", + "eventsFailed", + "destinations" + ] + }, + "recentErrors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string", + "maxLength": 256 + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "firstSeen": { + "type": "string", + "format": "date-time" + }, + "lastSeen": { + "type": "string", + "format": "date-time" + } + }, + "required": ["message", "count", "firstSeen", "lastSeen"] + }, + "maxItems": 50 + }, + "recentLogs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "level": { + "type": "string", + "enum": ["error", "warn", "info", "debug"] + }, + "message": { + "type": "string", + "maxLength": 256 + } + }, + "required": ["time", "level", "message"] + }, + "maxItems": 100 + } + }, + "required": ["instanceId", "flowId"] + }, + "OAuthClientRegistrationResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "example": "client_abc" + }, + "client_id_issued_at": { + "type": "integer", + "example": 1725400000 + }, + "client_name": { + "type": "string" + }, + "redirect_uris": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_endpoint_auth_method": { + "type": "string", + "enum": ["none"] + }, + "grant_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "response_types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "client_id", + "client_id_issued_at", + "client_name", + "redirect_uris", + "token_endpoint_auth_method", + "grant_types", + "response_types" + ] + }, + "OAuthRegistrationError": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["invalid_client_metadata", "invalid_redirect_uri"] + }, + "error_description": { + "type": "string" + } + }, + "required": ["error", "error_description"] + }, + "OAuthClientRegistrationRequest": { + "type": "object", + "properties": { + "redirect_uris": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1, + "maxItems": 10, + "example": ["https://claude.ai/api/mcp/auth_callback"] + }, + "client_name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "token_endpoint_auth_method": { + "type": "string", + "enum": ["none"] + }, + "grant_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["authorization_code", "refresh_token"] + }, + "minItems": 1 + }, + "response_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["code"] + } + }, + "client_uri": { + "type": "string", + "format": "uri" + }, + "logo_uri": { + "type": "string", + "format": "uri" + }, + "scope": { + "type": "string" + }, + "software_id": { + "type": "string" + }, + "software_version": { + "type": "string" + } + }, + "required": ["redirect_uris"] + }, + "DeviceAuthorizationResponse": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "user_code": { + "type": "string", + "example": "WDJB-MJHT" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "example": 900 + }, + "interval": { + "type": "integer", + "example": 5 + } + }, + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ] + }, + "OAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid_client" + }, + "error_description": { + "type": "string" + } + }, + "required": ["error", "error_description"] + }, + "DeviceAuthorizationRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "example": "walkeros-cli" + }, + "scope": { + "type": "string", + "example": "read write offline_access" + }, + "resource": { + "type": "string", + "example": "https://app.walkeros.io/api" + } + }, + "required": ["client_id"] + }, + "TokenResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "token_type": { + "type": "string", + "enum": ["Bearer"] + }, + "expires_in": { + "type": "integer", + "example": 3600 + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string", + "example": "read write offline_access" + } + }, + "required": ["access_token", "token_type", "expires_in", "scope"] + }, + "TokenRequest": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code" + ], + "example": "authorization_code" + }, + "client_id": { + "type": "string", + "example": "walkeros-cli" + }, + "client_secret": { + "type": "string" + }, + "code": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "code_verifier": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "device_code": { + "type": "string" + }, + "scope": { + "type": "string", + "example": "read offline_access" + }, + "resource": { + "type": "string", + "example": "https://app.walkeros.io/api" + } + }, + "required": ["grant_type"] + }, + "RevocationRequest": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "token_type_hint": { + "type": "string", + "enum": ["access_token", "refresh_token"] + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + } + }, + "required": ["token"] + }, + "DeviceApprovalResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "decision": { + "type": "string", + "enum": ["approve", "deny"] + } + }, + "required": ["success", "decision"] + }, + "DeviceApprovalRequest": { + "type": "object", + "properties": { + "userCode": { + "type": "string", + "minLength": 1, + "example": "WDJB-MJHT" + }, + "decision": { + "type": "string", + "enum": ["approve", "deny"] + } + }, + "required": ["userCode", "decision"] + }, + "OAuthConsentDecisionResponse": { + "type": "object", + "properties": { + "redirectTo": { + "type": "string", + "example": "https://claude.ai/api/mcp/auth_callback?code=abc&state=xyz" + } + }, + "required": ["redirectTo"] + }, + "OAuthConsentDecisionRequest": { + "type": "object", + "properties": { + "ticket": { + "type": "string", + "minLength": 1 + }, + "decision": { + "type": "string", + "enum": ["allow", "deny"] + } + }, + "required": ["ticket", "decision"] + }, + "ListOAuthGrantsResponse": { + "type": "object", + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuthGrantSummary" + } + } + }, + "required": ["grants"] + }, + "OAuthGrantSummary": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientName": { + "type": "string" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "lastUsedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + } + }, + "required": [ + "id", + "clientId", + "clientName", + "scope", + "createdAt", + "lastUsedAt" + ] + }, + "ListOAuthClientsResponse": { + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuthClientSummary" + } + } + }, + "required": ["clients"] + }, + "OAuthClientSummary": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["dcr", "cimd", "confidential", "builtin"] + }, + "name": { + "type": "string" + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "tokenEndpointAuthMethod": { + "type": "string", + "enum": ["none", "client_secret_basic", "client_secret_post"] + }, + "allowedResources": { + "type": "array", + "items": { + "type": "string", + "enum": ["mcp", "api"] + } + }, + "revokedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + } + }, + "required": [ + "clientId", + "kind", + "name", + "redirectUris", + "grantTypes", + "tokenEndpointAuthMethod", + "allowedResources", + "revokedAt" + ] + }, + "CreateOAuthClientResponse": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["dcr", "cimd", "confidential", "builtin"] + }, + "name": { + "type": "string" + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "tokenEndpointAuthMethod": { + "type": "string", + "enum": ["none", "client_secret_basic", "client_secret_post"] + }, + "allowedResources": { + "type": "array", + "items": { + "type": "string", + "enum": ["mcp", "api"] + } + }, + "revokedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-01-26T14:30:00.000Z" + }, + "clientSecret": { + "type": "string" + } + }, + "required": [ + "clientId", + "kind", + "name", + "redirectUris", + "grantTypes", + "tokenEndpointAuthMethod", + "allowedResources", + "revokedAt", + "clientSecret" + ] + }, + "CreateOAuthClientRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 10 + }, + "grantTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["authorization_code", "refresh_token"] + }, + "minItems": 1, + "default": ["authorization_code", "refresh_token"] + }, + "allowedResources": { + "type": "array", + "items": { + "type": "string", + "enum": ["mcp", "api"] + }, + "minItems": 1, + "default": ["mcp", "api"] + }, + "authMethod": { + "type": "string", + "enum": ["client_secret_basic", "client_secret_post"], + "default": "client_secret_basic" + } + }, + "required": ["name", "redirectUris"] + } + }, + "parameters": {} + }, + "paths": { + "/api/auth/magic-link": { + "post": { + "tags": ["Auth"], + "summary": "Request magic link", + "description": "Send a magic link to the provided email address for passwordless authentication. Always returns success to prevent email enumeration.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MagicLinkRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Magic link sent (or would be sent if email exists)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MagicLinkResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/auth/verify": { + "post": { + "tags": ["Auth"], + "summary": "Redeem magic link token", + "description": "Redeem a magic link token and create an authenticated session. Redeems immediately when the browser-nonce cookie from the magic-link request matches; otherwise answers confirm_required and expects a follow-up call with confirm: true.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Redemption result. status=ok sets the session cookie and carries the redirect target.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "get": { + "tags": ["Auth"], + "summary": "Legacy magic link entry point", + "description": "Side-effect-free redirector to the /auth/verify page for links minted before the page-URL change. Never consumes the token; redemption happens via POST.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "abc123...", + "description": "Magic link token" + }, + "required": true, + "description": "Magic link token", + "name": "token", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "/dashboard", + "description": "Redirect URL after verification" + }, + "required": false, + "description": "Redirect URL after verification", + "name": "redirect_to", + "in": "query" + } + ], + "responses": { + "307": { + "description": "Redirect to the /auth/verify page with the token forwarded" + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": ["Auth"], + "summary": "End session", + "description": "Destroy the current session and clear the session cookie.", + "responses": { + "302": { + "description": "Redirect to login page" + } + } + } + }, + "/api/auth/whoami": { + "get": { + "tags": ["Auth"], + "summary": "Current identity", + "description": "Return the identity of the authenticated user. Supports session cookie and Bearer token.", + "responses": { + "200": { + "description": "Current user identity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/account": { + "delete": { + "tags": ["Account"], + "summary": "Delete own account", + "description": "Soft-delete the authenticated account, starting the 30-day grace window. Requires a confirmation of the account email in the body. Revokes all sessions, API tokens, and MCP tokens. Blocked with 409 when the caller is the sole owner of a project that still has other members.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAccountRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Account scheduled for deletion" + }, + "400": { + "description": "Confirmation email does not match", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Sole owner of a shared project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAccountBlocked" + } + } + } + } + } + } + }, + "/api/account/export": { + "get": { + "tags": ["Account"], + "summary": "Export own account data", + "description": "Download a portable JSON export of everything the platform holds about the authenticated account: profile, memberships, token and session metadata, MCP sessions with messages, feedback, and invitations. Metadata only; token hashes and secret values are never included. Served as a file attachment.", + "responses": { + "200": { + "description": "Account data export (file attachment)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountExportResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/sessions": { + "get": { + "tags": ["Auth"], + "summary": "List sessions", + "description": "List all active sessions for the authenticated user. The current session is marked with isCurrent: true.", + "responses": { + "200": { + "description": "List of active sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSessionsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/sessions/{id}": { + "delete": { + "tags": ["Auth"], + "summary": "Revoke session", + "description": "Revoke a session by ID. Cannot revoke the current session (use logout instead).", + "parameters": [ + { + "schema": { + "type": "string", + "example": "ses_abc123" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Session revoked" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects": { + "get": { + "tags": ["Projects"], + "summary": "List my projects", + "description": "List all projects where the authenticated user is a member.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListProjectsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Projects"], + "summary": "Create project", + "description": "Create a new project. The authenticated user becomes the owner.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Project created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}": { + "get": { + "tags": ["Projects"], + "summary": "Get project", + "description": "Get a single project by ID. Requires membership.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Project details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDetailResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": ["Projects"], + "summary": "Update project", + "description": "Update project details. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Project updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["Projects"], + "summary": "Delete project", + "description": "Delete a project and all its resources. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Project deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/members": { + "get": { + "tags": ["Projects"], + "summary": "List members", + "description": "List all members of a project. Requires membership.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of members", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMembersResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Projects"], + "summary": "Add member", + "description": "Add a member to the project by email. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Member added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/members/{userId}": { + "patch": { + "tags": ["Projects"], + "summary": "Update member role", + "description": "Update a member's role. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "user_a1b2c3" + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMemberRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Role updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["Projects"], + "summary": "Remove member", + "description": "Remove a member from the project. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "user_a1b2c3" + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Member removed" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/flows": { + "get": { + "tags": ["Flows"], + "summary": "List flows", + "description": "List all flows for a project.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["name", "updated_at", "created_at"], + "example": "updated_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "example": "desc" + }, + "required": false, + "name": "order", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["true", "false"], + "example": "false" + }, + "required": false, + "name": "include_deleted", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of flows", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListFlowsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Flows"], + "summary": "Create flow", + "description": "Create a new flow in the project. Requires member role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFlowRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Flow created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}": { + "get": { + "tags": ["Flows"], + "summary": "Get flow", + "description": "Get a single flow by ID. Use ?fields to select specific sections (reduces response size).", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Comma-separated dot-paths to select specific fields (e.g., \"config.variables,config.flows.tracking.sources\"). Always includes id, createdAt, updatedAt.", + "example": "config.variables,config.flows.tracking.sources" + }, + "required": false, + "description": "Comma-separated dot-paths to select specific fields (e.g., \"config.variables,config.flows.tracking.sources\"). Always includes id, createdAt, updatedAt.", + "name": "fields", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Flow details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowDetailResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": ["Flows"], + "summary": "Update flow", + "description": "Update an existing flow. Creates a version snapshot before applying changes. Requires member role. Use Content-Type: application/merge-patch+json to send only changed fields (RFC 7386).", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "ETag from a previous GET. Returns 412 if flow was modified since.", + "example": "\"a1b2c3d4e5f6g7h8\"" + }, + "required": false, + "description": "ETag from a previous GET. Returns 412 if flow was modified since.", + "name": "if-match", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFlowRequest" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFlowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Flow updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowUpdateResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "412": { + "description": "ETag mismatch — flow was modified since last read" + } + } + }, + "delete": { + "tags": ["Flows"], + "summary": "Soft-delete flow", + "description": "Soft delete a flow (sets deleted_at timestamp). Requires member role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "ETag from a previous GET. Returns 412 if flow was modified since.", + "example": "\"a1b2c3d4e5f6g7h8\"" + }, + "required": false, + "description": "ETag from a previous GET. Returns 412 if flow was modified since.", + "name": "if-match", + "in": "header" + } + ], + "responses": { + "204": { + "description": "Flow deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "412": { + "description": "ETag mismatch — flow was modified since last read" + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}/duplicate": { + "post": { + "tags": ["Flows"], + "summary": "Duplicate flow", + "description": "Create a copy of an existing flow with a new ID and no version history. Requires member role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuplicateFlowRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Flow duplicated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}/secrets": { + "get": { + "tags": ["Secrets"], + "summary": "List secrets", + "description": "List a flow's secrets as metadata only (name, id, timestamps). Values are never returned. Requires member role and the secrets entitlement.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Secret metadata list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "flowId", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": ["secrets"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, - "deploymentId": { - "type": "string", - "example": "dpl_abc123" + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Secrets"], + "summary": "Create secret", + "description": "Create a secret for a flow. The value is encrypted at rest and never returned. Requires member role and the secrets entitlement.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" }, - "counters": { - "type": "object", - "properties": { - "eventsIn": { - "type": "integer", - "minimum": 0 - }, - "eventsOut": { - "type": "integer", - "minimum": 0 - }, - "eventsFailed": { - "type": "integer", - "minimum": 0 - }, - "destinations": { + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object", - "additionalProperties": { + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": ["name", "value"] + } + } + } + }, + "responses": { + "201": { + "description": "Secret created (metadata only)", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { - "count": { - "type": "integer", - "minimum": 0 + "id": { + "type": "string" }, - "failed": { - "type": "integer", - "minimum": 0 + "name": { + "type": "string" }, - "duration": { - "type": "number", - "minimum": 0 + "flowId": { + "type": "string" }, - "dlqSize": { - "type": "integer", - "minimum": 0 + "createdAt": { + "type": ["string", "null"], + "format": "date-time" }, - "dropped": { - "type": "integer", - "minimum": 0 + "updatedAt": { + "type": ["string", "null"], + "format": "date-time" } }, - "required": ["count", "failed", "duration"] + "required": ["id", "name", "flowId", "createdAt", "updatedAt"] } } - }, - "required": [ - "eventsIn", - "eventsOut", - "eventsFailed", - "destinations" - ] + } }, - "recentErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string", - "maxLength": 256 - }, - "count": { - "type": "integer", - "minimum": 1 - }, - "firstSeen": { - "type": "string", - "format": "date-time" - }, - "lastSeen": { - "type": "string", - "format": "date-time" + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } - }, - "required": ["message", "count", "firstSeen", "lastSeen"] - }, - "maxItems": 50 + } + } }, - "recentLogs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "time": { - "type": "string", - "format": "date-time" - }, - "level": { - "type": "string", - "enum": ["error", "warn", "info", "debug"] - }, - "message": { - "type": "string", - "maxLength": 256 + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } - }, - "required": ["time", "level", "message"] - }, - "maxItems": 100 + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - }, - "required": ["instanceId", "flowId"] + } } }, - "parameters": {} - }, - "paths": { - "/api/auth/magic-link": { - "post": { - "tags": ["Auth"], - "summary": "Request magic link", - "description": "Send a magic link to the provided email address for passwordless authentication. Always returns success to prevent email enumeration.", + "/api/projects/{projectId}/flows/{flowId}/secrets/{secretId}": { + "put": { + "tags": ["Secrets"], + "summary": "Update secret value", + "description": "Rotate a secret's value (re-encrypts). The value is never returned. Requires member role and the secrets entitlement.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "sec_abc123" + }, + "required": true, + "name": "secretId", + "in": "path" + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MagicLinkRequest" + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": ["value"] } } } }, "responses": { "200": { - "description": "Magic link sent (or would be sent if email exists)", + "description": "Secret updated (metadata only)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MagicLinkResponse" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time" + } + }, + "required": ["id", "name", "flowId", "createdAt", "updatedAt"] } } } @@ -6027,37 +9588,29 @@ } } } - } - } - } - }, - "/api/auth/verify": { - "post": { - "tags": ["Auth"], - "summary": "Redeem magic link token", - "description": "Redeem a magic link token and create an authenticated session. Redeems immediately when the browser-nonce cookie from the magic-link request matches; otherwise answers confirm_required and expects a follow-up call with confirm: true.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyRequest" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } - } - }, - "responses": { - "200": { - "description": "Redemption result. status=ok sets the session cookie and carries the redirect target.", + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -6068,66 +9621,111 @@ } } }, - "get": { - "tags": ["Auth"], - "summary": "Legacy magic link entry point", - "description": "Side-effect-free redirector to the /auth/verify page for links minted before the page-URL change. Never consumes the token; redemption happens via POST.", + "delete": { + "tags": ["Secrets"], + "summary": "Delete secret", + "description": "Soft-delete a secret. Idempotent: deleting a missing secret returns 204. Requires member role and the secrets entitlement.", "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "example": "abc123...", - "description": "Magic link token" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "description": "Magic link token", - "name": "token", - "in": "query" + "name": "projectId", + "in": "path" }, { "schema": { "type": "string", - "example": "/dashboard", - "description": "Redirect URL after verification" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, - "required": false, - "description": "Redirect URL after verification", - "name": "redirect_to", - "in": "query" + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "sec_abc123" + }, + "required": true, + "name": "secretId", + "in": "path" } ], "responses": { - "307": { - "description": "Redirect to the /auth/verify page with the token forwarded" - } - } - } - }, - "/api/auth/logout": { - "post": { - "tags": ["Auth"], - "summary": "End session", - "description": "Destroy the current session and clear the session cookie.", - "responses": { - "302": { - "description": "Redirect to login page" + "204": { + "description": "Secret deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/auth/whoami": { + "/api/projects/{projectId}/flows/{flowId}/secrets/values": { "get": { - "tags": ["Auth"], - "summary": "Current identity", - "description": "Return the identity of the authenticated user. Supports session cookie and Bearer token.", + "tags": ["Secrets"], + "summary": "Get decrypted secret values", + "description": "Return decrypted secret values for a flow as a name-to-value map. Dual auth: a runtime container Bearer token bound to (projectId, flowId) with the `runner:read-secrets` scope returns only the bundle-referenced subset; a session cookie with member role returns all of the flow's secrets for administration. Responses are never cached.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], "responses": { "200": { - "description": "Current user identity", + "description": "Decrypted secret values keyed by name", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WhoamiResponse" + "type": "object", + "properties": { + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["values"] } } } @@ -6141,30 +9739,90 @@ } } } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/account": { - "delete": { - "tags": ["Account"], - "summary": "Delete own account", - "description": "Soft-delete the authenticated account, starting the 30-day grace window. Requires a confirmation of the account email in the body. Revokes all sessions, API tokens, and MCP tokens. Blocked with 409 when the caller is the sole owner of a project that still has other members.", + "/api/projects/{projectId}/flows/{flowId}/steps/{stepPath}/examples": { + "post": { + "tags": ["Flows"], + "summary": "Add step example", + "description": "Add a named example to a step. Examples are stored as an object map keyed by name. Rejects duplicate names with 409.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "example": "destinations.gtag" + }, + "required": true, + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "name": "stepPath", + "in": "path" + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteAccountRequest" + "$ref": "#/components/schemas/CreateStepExampleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated examples object map", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepExamplesResponse" + } } } - } - }, - "responses": { - "204": { - "description": "Account scheduled for deletion" }, "400": { - "description": "Confirmation email does not match", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -6183,37 +9841,18 @@ } } }, - "409": { - "description": "Sole owner of a shared project", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteAccountBlocked" - } - } - } - } - } - } - }, - "/api/account/export": { - "get": { - "tags": ["Account"], - "summary": "Export own account data", - "description": "Download a portable JSON export of everything the platform holds about the authenticated account: profile, memberships, token and session metadata, MCP sessions with messages, feedback, and invitations. Metadata only; token hashes and secret values are never included. Served as a file attachment.", - "responses": { - "200": { - "description": "Account data export (file attachment)", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountExportResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "401": { - "description": "Unauthorized", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -6221,28 +9860,9 @@ } } } - } - } - } - }, - "/api/sessions": { - "get": { - "tags": ["Auth"], - "summary": "List sessions", - "description": "List all active sessions for the authenticated user. The current session is marked with isCurrent: true.", - "responses": { - "200": { - "description": "List of active sessions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSessionsResponse" - } - } - } }, - "401": { - "description": "Unauthorized", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -6252,30 +9872,66 @@ } } } - } - }, - "/api/sessions/{id}": { - "delete": { - "tags": ["Auth"], - "summary": "Revoke session", - "description": "Revoke a session by ID. Cannot revoke the current session (use logout instead).", + }, + "put": { + "tags": ["Flows"], + "summary": "Edit step example", + "description": "Edit an existing named example in place, merging provided fields onto the stored entry. Returns 404 when the named example does not exist.", "parameters": [ { "schema": { "type": "string", - "example": "ses_abc123" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "name": "id", + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "example": "destinations.gtag" + }, + "required": true, + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "name": "stepPath", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditStepExampleRequest" + } + } + } + }, "responses": { - "204": { - "description": "Session revoked" + "200": { + "description": "Updated examples object map", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepExamplesResponse" + } + } + } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -6284,8 +9940,8 @@ } } }, - "403": { - "description": "Forbidden", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -6303,50 +9959,75 @@ } } } - } - } - } - }, - "/api/auth/device/code": { - "post": { - "tags": ["Auth"], - "summary": "Request device code", - "description": "Generate a device code and user code for the device authorization flow.", - "responses": { - "200": { - "description": "Device code generated", + }, + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceCodeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } } } - } - }, - "/api/auth/device/approve": { - "post": { - "tags": ["Auth"], - "summary": "Approve device code", - "description": "Approve a device authorization request using the user code. Requires authentication.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApproveDeviceRequest" - } - } + }, + "delete": { + "tags": ["Flows"], + "summary": "Remove step example", + "description": "Remove a named example from a step. Returns 404 when the named example does not exist.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "example": "destinations.gtag" + }, + "required": true, + "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", + "name": "stepPath", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Name of the example to remove.", + "example": "product view" + }, + "required": true, + "description": "Name of the example to remove.", + "name": "name", + "in": "query" } - }, + ], "responses": { "200": { - "description": "Device approved", + "description": "Updated examples object map", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApproveDeviceResponse" + "$ref": "#/components/schemas/StepExamplesResponse" } } } @@ -6370,37 +10051,9 @@ } } } - } - } - } - }, - "/api/auth/device/token": { - "post": { - "tags": ["Auth"], - "summary": "Poll device token", - "description": "Poll for authorization status using the device code. Returns a token when approved.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceTokenRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Authorization approved", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceTokenResponse" - } - } - } }, - "400": { - "description": "Pending, slow down, or expired", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -6412,38 +10065,59 @@ } } }, - "/api/projects": { - "get": { - "tags": ["Projects"], - "summary": "List my projects", - "description": "List all projects where the authenticated user is a member.", + "/api/projects/{projectId}/flows/{flowId}/observe-examples": { + "post": { + "tags": ["Observe Sessions"], + "summary": "Save an observed hop as a step example", + "description": "Persist an observed journey hop as a named example on a step of the DRAFT flow config. Gated by the 'observe' feature. The step path and scenario come from the body; `example.in` is stored verbatim (post-redaction). Rejects a duplicate scenario name with 409.", "parameters": [ { "schema": { - "type": "string" + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, - "required": false, - "name": "cursor", - "in": "query" + "required": true, + "name": "projectId", + "in": "path" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObserveSaveExampleRequest" + } + } + } + }, "responses": { "200": { - "description": "List of projects", + "description": "Updated examples object map", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListProjectsResponse" + "$ref": "#/components/schemas/StepExamplesResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6457,35 +10131,29 @@ } } } - } - } - }, - "post": { - "tags": ["Projects"], - "summary": "Create project", - "description": "Create a new project. The authenticated user becomes the owner.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProjectRequest" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } - } - }, - "responses": { - "201": { - "description": "Project created", + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateProjectResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -6494,8 +10162,8 @@ } } }, - "401": { - "description": "Unauthorized", + "422": { + "description": "Unprocessable entity", "content": { "application/json": { "schema": { @@ -6507,11 +10175,11 @@ } } }, - "/api/projects/{projectId}": { + "/api/projects/{projectId}/flows/{flowId}/deploy": { "get": { - "tags": ["Projects"], - "summary": "Get project", - "description": "Get a single project by ID. Requires membership.", + "tags": ["Deployments"], + "summary": "Get latest deployment", + "description": "Get the latest deployment for a flow.", "parameters": [ { "schema": { @@ -6522,15 +10190,25 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" } ], "responses": { "200": { - "description": "Project details", + "description": "Latest deployment (or null)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectDetailResponse" + "$ref": "#/components/schemas/DeploymentResponse" } } } @@ -6557,10 +10235,10 @@ } } }, - "patch": { - "tags": ["Projects"], - "summary": "Update project", - "description": "Update project details. Requires owner role.", + "post": { + "tags": ["Deployments"], + "summary": "Start deployment", + "description": "Start a new deployment for a flow. The bundle runs asynchronously on the worker. Returns 400 AMBIGUOUS_CONFIG when the flow has multiple named settings (use the per-settings deploy endpoint instead). When an Idempotency-Key replays a prior request, returns 200 with status `already_created`.", "parameters": [ { "schema": { @@ -6571,24 +10249,35 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProjectRequest" - } - } - } - }, "responses": { "200": { - "description": "Project updated", + "description": "Deployment started, or idempotent replay of a prior request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateProjectResponse" + "$ref": "#/components/schemas/StartDeploymentResponse" + } + } + } + }, + "201": { + "description": "Deployment started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartDeploymentResponse" } } } @@ -6632,31 +10321,9 @@ } } } - } - } - }, - "delete": { - "tags": ["Projects"], - "summary": "Delete project", - "description": "Delete a project and all its resources. Requires owner role.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "Project deleted" }, - "401": { - "description": "Unauthorized", + "409": { + "description": "Deployment already in progress", "content": { "application/json": { "schema": { @@ -6665,8 +10332,8 @@ } } }, - "403": { - "description": "Forbidden", + "429": { + "description": "Rate limited or concurrent deploy limit (Retry-After header)", "content": { "application/json": { "schema": { @@ -6675,8 +10342,8 @@ } } }, - "404": { - "description": "Not found", + "503": { + "description": "Service unavailable", "content": { "application/json": { "schema": { @@ -6688,11 +10355,11 @@ } } }, - "/api/projects/{projectId}/members": { + "/api/projects/{projectId}/flows/{flowId}/deploy/{deploymentId}": { "get": { - "tags": ["Projects"], - "summary": "List members", - "description": "List all members of a project. Requires membership.", + "tags": ["Deployments"], + "summary": "Get deployment", + "description": "Get a specific deployment by ID.", "parameters": [ { "schema": { @@ -6703,15 +10370,34 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "dep_abc123" + }, + "required": true, + "name": "deploymentId", + "in": "path" } ], "responses": { "200": { - "description": "List of members", + "description": "Deployment details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListMembersResponse" + "$ref": "#/components/schemas/DeploymentDetailResponse" } } } @@ -6738,10 +10424,10 @@ } } }, - "post": { - "tags": ["Projects"], - "summary": "Add member", - "description": "Add a member to the project by email. Requires owner role.", + "delete": { + "tags": ["Deployments"], + "summary": "Delete deployment", + "description": "Delete a deployment and its container. Requires owner role.", "parameters": [ { "schema": { @@ -6752,34 +10438,44 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "dep_abc123" + }, + "required": true, + "name": "deploymentId", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddMemberRequest" - } - } - } - }, "responses": { - "201": { - "description": "Member added", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Member" - } - } - } - }, - "400": { - "description": "Validation error", + "200": { + "description": "Deployment deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["deleted"] + } + }, + "required": ["id", "status"] } } } @@ -6813,25 +10509,15 @@ } } } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/members/{userId}": { - "patch": { - "tags": ["Projects"], - "summary": "Update member role", - "description": "Update a member's role. Requires owner role.", + "/api/projects/{projectId}/flows/{flowId}/settings": { + "get": { + "tags": ["Settings"], + "summary": "List settings", + "description": "List active named settings for a flow.", "parameters": [ { "schema": { @@ -6846,35 +10532,27 @@ { "schema": { "type": "string", - "example": "user_a1b2c3" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "userId", + "name": "flowId", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMemberRequest" - } - } - } - }, "responses": { "200": { - "description": "Role updated", + "description": "List of settings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Member" + "$ref": "#/components/schemas/ListSettingsResponse" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -6883,8 +10561,8 @@ } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -6892,9 +10570,60 @@ } } } + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}": { + "get": { + "tags": ["Settings"], + "summary": "Get settings", + "description": "Get a single settings entry with its latest deployment.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" + }, + "required": true, + "name": "settingsId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Settings details with deployment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowSettingsDetail" + } + } + } }, - "403": { - "description": "Forbidden", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -6914,11 +10643,13 @@ } } } - }, - "delete": { - "tags": ["Projects"], - "summary": "Remove member", - "description": "Remove a member from the project. Requires owner role.", + } + }, + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/json": { + "get": { + "tags": ["Settings"], + "summary": "Download settings JSON", + "description": "Download the named flow settings as a self-contained Config JSON file. Includes parent variables and definitions.", "parameters": [ { "schema": { @@ -6933,29 +10664,37 @@ { "schema": { "type": "string", - "example": "user_a1b2c3" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "userId", + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" + }, + "required": true, + "name": "settingsId", "in": "path" } ], "responses": { - "204": { - "description": "Member removed" - }, - "401": { - "description": "Unauthorized", + "200": { + "description": "Flow Config JSON file", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FlowConfig" } } } }, - "403": { - "description": "Forbidden", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -6977,11 +10716,11 @@ } } }, - "/api/projects/{projectId}/flows": { + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/bundle": { "get": { - "tags": ["Flows"], - "summary": "List flows", - "description": "List all flows for a project.", + "tags": ["Settings"], + "summary": "Download settings bundle", + "description": "Download the compiled JS/MJS for the settings' latest deployment. Redirects to a presigned download URL.", "parameters": [ { "schema": { @@ -6996,65 +10735,40 @@ { "schema": { "type": "string", - "enum": ["name", "updated_at", "created_at"], - "example": "updated_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "example": "desc" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, - "required": false, - "name": "order", - "in": "query" + "required": true, + "name": "flowId", + "in": "path" }, { "schema": { "type": "string", - "enum": ["true", "false"], - "example": "false" - }, - "required": false, - "name": "include_deleted", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" }, - "required": false, - "name": "limit", - "in": "query" + "required": true, + "name": "settingsId", + "in": "path" } ], "responses": { - "200": { - "description": "List of flows", + "302": { + "description": "Redirect to presigned bundle URL" + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListFlowsResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -7063,8 +10777,8 @@ } } }, - "404": { - "description": "Not found", + "503": { + "description": "Service unavailable", "content": { "application/json": { "schema": { @@ -7074,11 +10788,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy": { "post": { - "tags": ["Flows"], - "summary": "Create flow", - "description": "Create a new flow in the project. Requires member role.", + "tags": ["Settings"], + "summary": "Deploy settings", + "description": "Start a deployment for a specific settings entry. Detects platform from the settings. The body is optional and carries only `humanText`, the reason for the change, which becomes the description of the release this deploy produces; it is ignored when the release already has one.", "parameters": [ { "schema": { @@ -7089,30 +10805,61 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" + }, + "required": true, + "name": "settingsId", + "in": "path" } ], "requestBody": { + "required": false, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFlowRequest" + "$ref": "#/components/schemas/DeploySettingsRequest" } } } }, "responses": { "201": { - "description": "Flow created", + "description": "Deployment started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploySettingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -7121,8 +10868,8 @@ } } }, - "401": { - "description": "Unauthorized", + "409": { + "description": "Deployment already in progress", "content": { "application/json": { "schema": { @@ -7131,8 +10878,8 @@ } } }, - "403": { - "description": "Forbidden", + "422": { + "description": "Settings orphaned", "content": { "application/json": { "schema": { @@ -7141,8 +10888,8 @@ } } }, - "409": { - "description": "Conflict", + "503": { + "description": "Service unavailable", "content": { "application/json": { "schema": { @@ -7152,13 +10899,11 @@ } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}": { + }, "get": { - "tags": ["Flows"], - "summary": "Get flow", - "description": "Get a single flow by ID. Use ?fields to select specific sections (reduces response size).", + "tags": ["Settings"], + "summary": "Get latest settings deployment", + "description": "Get the latest deployment for a specific settings entry.", "parameters": [ { "schema": { @@ -7183,22 +10928,21 @@ { "schema": { "type": "string", - "description": "Comma-separated dot-paths to select specific fields (e.g., \"config.variables,config.flows.tracking.sources\"). Always includes id, createdAt, updatedAt.", - "example": "config.variables,config.flows.tracking.sources" + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" }, - "required": false, - "description": "Comma-separated dot-paths to select specific fields (e.g., \"config.variables,config.flows.tracking.sources\"). Always includes id, createdAt, updatedAt.", - "name": "fields", - "in": "query" + "required": true, + "name": "settingsId", + "in": "path" } ], "responses": { "200": { - "description": "Flow details", + "description": "Latest deployment (or null)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FlowDetailResponse" + "$ref": "#/components/schemas/SettingsDeploymentResponse" } } } @@ -7224,11 +10968,13 @@ } } } - }, - "patch": { - "tags": ["Flows"], - "summary": "Update flow", - "description": "Update an existing flow. Creates a version snapshot before applying changes. Requires member role. Use Content-Type: application/merge-patch+json to send only changed fields (RFC 7386).", + } + }, + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deployments/{deploymentId}": { + "get": { + "tags": ["Settings"], + "summary": "Get settings deployment detail", + "description": "Get a specific deployment by ID, scoped to a settings entry.", "parameters": [ { "schema": { @@ -7253,46 +10999,30 @@ { "schema": { "type": "string", - "description": "ETag from a previous GET. Returns 412 if flow was modified since.", - "example": "\"a1b2c3d4e5f6g7h8\"" + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" }, - "required": false, - "description": "ETag from a previous GET. Returns 412 if flow was modified since.", - "name": "if-match", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFlowRequest" - } + "required": true, + "name": "settingsId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "dep_abc123" }, - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFlowRequest" - } - } + "required": true, + "name": "deploymentId", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Flow updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FlowUpdateResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Deployment details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SettingsDeploymentDetailResponse" } } } @@ -7307,16 +11037,6 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -7326,26 +11046,15 @@ } } } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "412": { - "description": "ETag mismatch — flow was modified since last read" } } - }, - "delete": { - "tags": ["Flows"], - "summary": "Soft-delete flow", - "description": "Soft delete a flow (sets deleted_at timestamp). Requires member role.", + } + }, + "/api/projects/{projectId}/flows/{flowId}/previews": { + "post": { + "tags": ["Previews"], + "summary": "Create preview", + "description": "Create a new preview for a web flow settings entry. Bundles the flow and publishes to a unique token-based URL.", "parameters": [ { "schema": { @@ -7366,22 +11075,37 @@ "required": true, "name": "flowId", "in": "path" - }, - { - "schema": { - "type": "string", - "description": "ETag from a previous GET. Returns 412 if flow was modified since.", - "example": "\"a1b2c3d4e5f6g7h8\"" - }, - "required": false, - "description": "ETag from a previous GET. Returns 412 if flow was modified since.", - "name": "if-match", - "in": "header" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePreviewRequest" + } + } + } + }, "responses": { - "204": { - "description": "Flow deleted" + "201": { + "description": "Preview created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePreviewResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -7413,17 +11137,32 @@ } } }, - "412": { - "description": "ETag mismatch — flow was modified since last read" + "429": { + "description": "Quota exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "Bundle or upload failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/duplicate": { - "post": { - "tags": ["Flows"], - "summary": "Duplicate flow", - "description": "Create a copy of an existing flow with a new ID and no version history. Requires member role.", + }, + "get": { + "tags": ["Previews"], + "summary": "List previews", + "description": "List all previews for a flow, ordered by creation date descending.", "parameters": [ { "schema": { @@ -7446,58 +11185,19 @@ "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DuplicateFlowRequest" - } - } - } - }, "responses": { - "201": { - "description": "Flow duplicated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Flow" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Forbidden", + "200": { + "description": "List of previews", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListPreviewsResponse" } } } }, - "404": { - "description": "Not found", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -7506,8 +11206,8 @@ } } }, - "409": { - "description": "Conflict", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -7519,11 +11219,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/secrets": { + "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}": { "get": { - "tags": ["Secrets"], - "summary": "List secrets", - "description": "List a flow's secrets as metadata only (name, id, timestamps). Values are never returned. Requires member role and the secrets entitlement.", + "tags": ["Previews"], + "summary": "Get preview", + "description": "Get a single preview by ID.", "parameters": [ { "schema": { @@ -7544,50 +11244,25 @@ "required": true, "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^prv_[a-z0-9]+$", + "example": "prv_abc123xyz456" + }, + "required": true, + "name": "previewId", + "in": "path" } ], "responses": { "200": { - "description": "Secret metadata list", + "description": "Preview details", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "secrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": [ - "id", - "name", - "flowId", - "createdAt", - "updatedAt" - ] - } - } - }, - "required": ["secrets"] + "$ref": "#/components/schemas/PreviewResponse" } } } @@ -7602,16 +11277,6 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -7624,10 +11289,10 @@ } } }, - "post": { - "tags": ["Secrets"], - "summary": "Create secret", - "description": "Create a secret for a flow. The value is encrypted at rest and never returned. Requires member role and the secrets entitlement.", + "delete": { + "tags": ["Previews"], + "summary": "Delete preview", + "description": "Delete a preview and its S3 bundle. Requires member role.", "parameters": [ { "schema": { @@ -7648,71 +11313,21 @@ "required": true, "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^prv_[a-z0-9]+$", + "example": "prv_abc123xyz456" + }, + "required": true, + "name": "previewId", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Z_][A-Z0-9_]*$" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 - } - }, - "required": ["name", "value"] - } - } - } - }, "responses": { - "201": { - "description": "Secret created (metadata only)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "204": { + "description": "Preview deleted" }, "401": { "description": "Unauthorized", @@ -7734,8 +11349,8 @@ } } }, - "409": { - "description": "Conflict", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -7747,11 +11362,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/secrets/{secretId}": { - "put": { - "tags": ["Secrets"], - "summary": "Update secret value", - "description": "Rotate a secret's value (re-encrypts). The value is never returned. Requires member role and the secrets entitlement.", + "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant": { + "post": { + "tags": ["Previews"], + "summary": "Mint preview activation grant", + "description": "Mint a fresh, origin-bound activation grant for an existing preview. Grants are origin-bound, so a preview needs one grant per host origin — re-mint whenever the target origin changes.", "parameters": [ { "schema": { @@ -7776,10 +11391,11 @@ { "schema": { "type": "string", - "example": "sec_abc123" + "pattern": "^prv_[a-z0-9]+$", + "example": "prv_abc123xyz456" }, "required": true, - "name": "secretId", + "name": "previewId", "in": "path" } ], @@ -7787,46 +11403,18 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "minLength": 1, - "maxLength": 65536 - } - }, - "required": ["value"] + "$ref": "#/components/schemas/MintGrantRequest" } } } }, "responses": { "200": { - "description": "Secret updated (metadata only)", + "description": "Grant minted", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "flowId": { - "type": "string" - }, - "createdAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": ["string", "null"], - "format": "date-time" - } - }, - "required": ["id", "name", "flowId", "createdAt", "updatedAt"] + "$ref": "#/components/schemas/MintGrantResponse" } } } @@ -7870,50 +11458,9 @@ } } } - } - } - }, - "delete": { - "tags": ["Secrets"], - "summary": "Delete secret", - "description": "Soft-delete a secret. Idempotent: deleting a missing secret returns 204. Requires member role and the secrets entitlement.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "example": "sec_abc123" - }, - "required": true, - "name": "secretId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "Secret deleted" }, - "401": { - "description": "Unauthorized", + "409": { + "description": "No active web deployment / host bundle not preview-enabled", "content": { "application/json": { "schema": { @@ -7922,8 +11469,8 @@ } } }, - "403": { - "description": "Forbidden", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -7935,11 +11482,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/secrets/values": { - "get": { - "tags": ["Secrets"], - "summary": "Get decrypted secret values", - "description": "Return decrypted secret values for a flow as a name-to-value map. Dual auth: a runtime container Bearer token bound to (projectId, flowId) with the `runner:read-secrets` scope returns only the bundle-referenced subset; a session cookie with member role returns all of the flow's secrets for administration. Responses are never cached.", + "/api/projects/{projectId}/flows/{flowId}/observe-sessions": { + "post": { + "tags": ["Observe Sessions"], + "summary": "Start observe session", + "description": "Start an Observe session for a flow. Validates the flow topology, inserts the row, and kicks off detached provisioning. Returns the row immediately as arming.", "parameters": [ { "schema": { @@ -7962,22 +11509,32 @@ "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateObserveSessionRequest" + } + } + } + }, "responses": { - "200": { - "description": "Decrypted secret values keyed by name", + "201": { + "description": "Observe session started", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "values": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["values"] + "$ref": "#/components/schemas/ObserveSessionResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8011,15 +11568,35 @@ } } } + }, + "409": { + "description": "Flow topology not supported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/flows/{flowId}/steps/{stepPath}/examples": { - "post": { - "tags": ["Flows"], - "summary": "Add step example", - "description": "Add a named example to a step. Examples are stored as an object map keyed by name. Rejects duplicate names with 409.", + "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}": { + "get": { + "tags": ["Observe Sessions"], + "summary": "Get observe session", + "description": "Get an observe session: status, error message, config snapshot, web activation info, and the live server endpoint when live.", "parameters": [ { "schema": { @@ -8044,41 +11621,21 @@ { "schema": { "type": "string", - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "example": "destinations.gtag" + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" }, "required": true, - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "name": "stepPath", + "name": "sessionId", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateStepExampleRequest" - } - } - } - }, "responses": { "200": { - "description": "Updated examples object map", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StepExamplesResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Observe session details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ObserveSessionResponse" } } } @@ -8093,18 +11650,8 @@ } } }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8113,8 +11660,8 @@ } } }, - "422": { - "description": "Unprocessable entity", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -8125,10 +11672,10 @@ } } }, - "put": { - "tags": ["Flows"], - "summary": "Edit step example", - "description": "Edit an existing named example in place, merging provided fields onto the stored entry. Returns 404 when the named example does not exist.", + "delete": { + "tags": ["Observe Sessions"], + "summary": "End observe session", + "description": "End an observe session: tear down the container, revoke credentials, delete the web preview, delete the row. Idempotent.", "parameters": [ { "schema": { @@ -8153,44 +11700,17 @@ { "schema": { "type": "string", - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "example": "destinations.gtag" + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" }, "required": true, - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "name": "stepPath", + "name": "sessionId", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EditStepExampleRequest" - } - } - } - }, "responses": { - "200": { - "description": "Updated examples object map", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StepExamplesResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } + "204": { + "description": "Observe session ended" }, "401": { "description": "Unauthorized", @@ -8202,8 +11722,8 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8212,8 +11732,8 @@ } } }, - "422": { - "description": "Unprocessable entity", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -8223,11 +11743,13 @@ } } } - }, - "delete": { - "tags": ["Flows"], - "summary": "Remove step example", - "description": "Remove a named example from a step. Returns 404 when the named example does not exist.", + } + }, + "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/journeys": { + "get": { + "tags": ["Observe Sessions"], + "summary": "Get observe session journeys", + "description": "Assemble the session's cross-runtime journeys server-side: fetch the raw records from the observer, derive the pipeline topology from the config snapshot, and run the pure assembler. Returns the journeys and per-platform loss gaps wrapped with the session scope.", "parameters": [ { "schema": { @@ -8252,34 +11774,21 @@ { "schema": { "type": "string", - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "example": "destinations.gtag" + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" }, "required": true, - "description": "Dot-segmented step path (sectionKey.stepName), e.g. \"destinations.gtag\".", - "name": "stepPath", + "name": "sessionId", "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Name of the example to remove.", - "example": "product view" - }, - "required": true, - "description": "Name of the example to remove.", - "name": "name", - "in": "query" } ], "responses": { "200": { - "description": "Updated examples object map", + "description": "Assembled journeys", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StepExamplesResponse" + "$ref": "#/components/schemas/ObserveSessionJourneysResponse" } } } @@ -8294,6 +11803,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -8304,8 +11823,8 @@ } } }, - "422": { - "description": "Unprocessable entity", + "502": { + "description": "Observer unavailable", "content": { "application/json": { "schema": { @@ -8317,11 +11836,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/observe-examples": { - "post": { + "/api/projects/{projectId}/flows/{flowId}/journeys": { + "get": { "tags": ["Observe Sessions"], - "summary": "Save an observed hop as a step example", - "description": "Persist an observed journey hop as a named example on a step of the DRAFT flow config. Gated by the 'observe' feature. The step path and scenario come from the body; `example.in` is stored verbatim (post-redaction). Rejects a duplicate scenario name with 409.", + "summary": "Get flow journeys", + "description": "Resolve the flow's single active Observe session and assemble its cross-runtime journeys server-side. `observe_sessions.flow_id` is UNIQUE, so a flow has at most one session; when none is active the response carries `sessionId: null` with empty journeys rather than a 404. Narrow with `traceId` (one trace) and `limit` (page cap, most recent kept). This is the MCP `observe_journeys` REST contract.", "parameters": [ { "schema": { @@ -8335,31 +11854,41 @@ }, { "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": false, + "name": "traceId", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 }, - "required": true, - "name": "flowId", - "in": "path" + "required": false, + "name": "limit", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveSaveExampleRequest" - } - } - } - }, "responses": { "200": { - "description": "Updated examples object map", + "description": "Assembled flow journeys", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StepExamplesResponse" + "$ref": "#/components/schemas/FlowJourneysResponse" } } } @@ -8404,18 +11933,8 @@ } } }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "Unprocessable entity", + "502": { + "description": "Observer unavailable", "content": { "application/json": { "schema": { @@ -8427,11 +11946,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/deploy": { - "get": { - "tags": ["Deployments"], - "summary": "Get latest deployment", - "description": "Get the latest deployment for a flow.", + "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/heartbeat": { + "post": { + "tags": ["Observe Sessions"], + "summary": "Heartbeat observe session", + "description": "Keep an observe session warm. The window posts this every 30s while open; a stale session is reaped by the janitor.", "parameters": [ { "schema": { @@ -8452,15 +11971,25 @@ "required": true, "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" + }, + "required": true, + "name": "sessionId", + "in": "path" } ], "responses": { "200": { - "description": "Latest deployment (or null)", + "description": "Heartbeat recorded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentResponse" + "$ref": "#/components/schemas/ObserveSessionHeartbeatResponse" } } } @@ -8486,11 +12015,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/end": { "post": { - "tags": ["Deployments"], - "summary": "Start deployment", - "description": "Start a new deployment for a flow. The bundle runs asynchronously on the worker. Returns 400 AMBIGUOUS_CONFIG when the flow has multiple named settings (use the per-settings deploy endpoint instead). When an Idempotency-Key replays a prior request, returns 200 with status `already_created`.", + "tags": ["Observe Sessions"], + "summary": "End observe session (beacon)", + "description": "The navigator.sendBeacon end target for page unload. Mirrors the DELETE end route because sendBeacon cannot send a DELETE. Idempotent.", "parameters": [ { "schema": { @@ -8511,31 +12042,34 @@ "required": true, "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^ses_[a-zA-Z0-9_-]+$", + "example": "ses_abc123xyz456" + }, + "required": true, + "name": "sessionId", + "in": "path" } ], "responses": { - "200": { - "description": "Deployment started, or idempotent replay of a prior request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartDeploymentResponse" - } - } - } + "204": { + "description": "Observe session ended" }, - "201": { - "description": "Deployment started", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StartDeploymentResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -8543,19 +12077,50 @@ } } } + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}/versions": { + "get": { + "tags": ["Versions"], + "summary": "List versions", + "description": "List all versions for a flow.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" }, - "401": { - "description": "Unauthorized", + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of versions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListVersionsResponse" } } } }, - "403": { - "description": "Forbidden", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8573,19 +12138,60 @@ } } } + } + } + } + }, + "/api/projects/{projectId}/flows/{flowId}/versions/{versionNumber}": { + "get": { + "tags": ["Versions"], + "summary": "Get version", + "description": "Get a specific version of a flow.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" }, - "409": { - "description": "Deployment already in progress", + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "example": 1 + }, + "required": true, + "name": "versionNumber", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Version details with content", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/GetVersionResponse" } } } }, - "429": { - "description": "Rate limited or concurrent deploy limit (Retry-After header)", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8594,8 +12200,8 @@ } } }, - "503": { - "description": "Service unavailable", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -8607,11 +12213,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/deploy/{deploymentId}": { - "get": { - "tags": ["Deployments"], - "summary": "Get deployment", - "description": "Get a specific deployment by ID.", + "/api/projects/{projectId}/flows/{flowId}/versions/{versionNumber}/restore": { + "post": { + "tags": ["Versions"], + "summary": "Restore version", + "description": "Restore a flow to a specific version. Creates a new version snapshot. Requires member role.", "parameters": [ { "schema": { @@ -8635,21 +12241,22 @@ }, { "schema": { - "type": "string", - "example": "dep_abc123" + "type": "integer", + "exclusiveMinimum": 0, + "example": 1 }, "required": true, - "name": "deploymentId", + "name": "versionNumber", "in": "path" } ], "responses": { "200": { - "description": "Deployment details", + "description": "Flow restored", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentDetailResponse" + "$ref": "#/components/schemas/Flow" } } } @@ -8664,8 +12271,47 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/tokens": { + "get": { + "tags": ["Tokens"], + "summary": "List my automation tokens", + "description": "The caller's live automation tokens, with the scope and audience each carries. No raw token value is ever returned; `tokenPrefix` is the only fragment of one that survives issuance. A connected app's access token lives in the same store and is deliberately absent: it is taken back by disconnecting the app.", + "responses": { + "200": { + "description": "List of automation tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAutomationTokensResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8676,58 +12322,36 @@ } } }, - "delete": { - "tags": ["Deployments"], - "summary": "Delete deployment", - "description": "Delete a deployment and its container. Requires owner role.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "example": "dep_abc123" - }, - "required": true, - "name": "deploymentId", - "in": "path" + "post": { + "tags": ["Tokens"], + "summary": "Create automation token", + "description": "Mint an automation token for the authenticated user. The audience is `api` and `mcp`, so one token works against REST and against `/api/mcp`, and the chosen scope decides how far it gets at either: `read` is refused every non-safe REST method with 403 `INSUFFICIENT_SCOPE`. The raw token is returned once and cannot be retrieved again, so the answer carries `Cache-Control: no-store`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAutomationTokenRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "Deployment deleted", + "201": { + "description": "Token created (raw token shown once)", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["deleted"] - } - }, - "required": ["id", "status"] + "$ref": "#/components/schemas/CreateAutomationTokenResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8742,8 +12366,8 @@ } } }, - "403": { - "description": "Forbidden", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -8751,9 +12375,21 @@ } } } + } + } + } + }, + "/api/tokens/revoke-all": { + "post": { + "tags": ["Tokens"], + "summary": "Revoke all access", + "description": "Revoke every grant this person holds, the tokens hanging from them, and every automation token they hold. Runner tokens survive: those are the credentials deployed flow containers run with, so revoking them would stop every container the person is running. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token cannot disconnect everything its owner has connected.", + "responses": { + "204": { + "description": "Access revoked" }, - "404": { - "description": "Not found", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8765,43 +12401,26 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/settings": { - "get": { - "tags": ["Settings"], - "summary": "List settings", - "description": "List active named settings for a flow.", + "/api/tokens/{tokenId}": { + "delete": { + "tags": ["Tokens"], + "summary": "Revoke automation token", + "description": "Revoke one of the caller's tokens. Idempotent and scoped to the caller: an unknown id, another person's token and an already revoked one all answer 204, since a distinguishable answer would tell the caller which ids exist.", "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "pattern": "^tok_[a-zA-Z0-9_-]+$", + "example": "tok_a1b2c3d4" }, "required": true, - "name": "flowId", + "name": "tokenId", "in": "path" } ], "responses": { - "200": { - "description": "List of settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListSettingsResponse" - } - } - } + "204": { + "description": "Token revoked" }, "401": { "description": "Unauthorized", @@ -8812,25 +12431,15 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}": { - "get": { - "tags": ["Settings"], - "summary": "Get settings", - "description": "Get a single settings entry with its latest deployment.", + "/api/projects/{projectId}/flows/{flowId}/bundle": { + "post": { + "tags": ["Bundle"], + "summary": "Bundle flow", + "description": "Bundle a flow using CLI. Returns bundleId (content-hash). Use ?output=download to redirect to presigned S3 URL.", "parameters": [ { "schema": { @@ -8855,25 +12464,40 @@ { "schema": { "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" + "example": "my-web-flow", + "description": "Named flow to bundle (required for multi-settings flows)" }, - "required": true, - "name": "settingsId", - "in": "path" + "required": false, + "description": "Named flow to bundle (required for multi-settings flows)", + "name": "flow", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["download"], + "description": "Set to \"download\" to redirect to the bundle file" + }, + "required": false, + "description": "Set to \"download\" to redirect to the bundle file", + "name": "output", + "in": "query" } ], "responses": { "200": { - "description": "Settings details with deployment", + "description": "Bundle result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FlowSettingsDetail" + "$ref": "#/components/schemas/BundleResponse" } } } }, + "302": { + "description": "Redirect to presigned bundle URL (when output=download)" + }, "401": { "description": "Unauthorized", "content": { @@ -8897,11 +12521,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/json": { - "get": { - "tags": ["Settings"], - "summary": "Download settings JSON", - "description": "Download the named flow settings as a self-contained Config JSON file. Includes parent variables and definitions.", + "/api/projects/{projectId}/flows/{flowId}/simulate": { + "post": { + "tags": ["Simulate"], + "summary": "Simulate a flow step", + "description": "Execute a simulation against a pre-built bundle. Requires bundleId from the bundle endpoint.", "parameters": [ { "schema": { @@ -8922,25 +12546,24 @@ "required": true, "name": "flowId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" - }, - "required": true, - "name": "settingsId", - "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimulateRequest" + } + } + } + }, "responses": { "200": { - "description": "Flow Config JSON file", + "description": "Simulation result", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FlowConfig" + "$ref": "#/components/schemas/SimulateResponse" } } } @@ -8964,15 +12587,25 @@ } } } + }, + "502": { + "description": "Simulation container error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/bundle": { + "/api/projects/{projectId}/deployments": { "get": { - "tags": ["Settings"], - "summary": "Download settings bundle", - "description": "Download the compiled JS/MJS for the settings' latest deployment. Redirects to a presigned download URL.", + "tags": ["Deployments"], + "summary": "List deployments", + "description": "List deployments for a project. Supports filtering by status, type, origin, and flowId, plus pagination.", "parameters": [ { "schema": { @@ -8984,43 +12617,109 @@ "name": "projectId", "in": "path" }, + { + "schema": { + "type": "string", + "enum": [ + "idle", + "deploying", + "published", + "active", + "stopped", + "failed" + ] + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["web", "server"] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["cloud", "self-hosted"] + }, + "required": false, + "name": "origin", + "in": "query" + }, { "schema": { "type": "string", "pattern": "^flow_[a-zA-Z0-9_-]+$", "example": "flow_a1b2c3d4" }, - "required": true, + "required": false, "name": "flowId", - "in": "path" + "in": "query" }, { "schema": { "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" + "enum": ["created_at", "updated_at"] }, - "required": true, - "name": "settingsId", - "in": "path" + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": ["integer", "null"], + "minimum": 0 + }, + "required": false, + "name": "offset", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" } ], "responses": { - "302": { - "description": "Redirect to presigned bundle URL" - }, - "401": { - "description": "Unauthorized", + "200": { + "description": "List of deployments", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListDeploymentsResponse" } } } }, - "404": { - "description": "Not found", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -9029,8 +12728,8 @@ } } }, - "503": { - "description": "Service unavailable", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9040,13 +12739,11 @@ } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy": { + }, "post": { - "tags": ["Settings"], - "summary": "Deploy settings", - "description": "Start a deployment for a specific settings entry. Detects platform from the settings.", + "tags": ["Deployments"], + "summary": "Create deployment", + "description": "Create a new deployment slot. Supports an Idempotency-Key header — a repeated key returns the original deployment id with status `already_created`. Requires member role.", "parameters": [ { "schema": { @@ -9061,37 +12758,54 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" + "description": "Optional client key to make creation idempotent." }, - "required": true, - "name": "settingsId", - "in": "path" + "required": false, + "description": "Optional client key to make creation idempotent.", + "name": "idempotency-key", + "in": "header" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["web", "server"], + "example": "web" + }, + "label": { + "type": "string", + "maxLength": 255 + }, + "flowId": { + "type": "string" + }, + "flowSettingsId": { + "type": "string" + } + }, + "required": ["type"] + } + } + } + }, "responses": { "201": { - "description": "Deployment started", + "description": "Deployment created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploySettingsResponse" + "$ref": "#/components/schemas/CreateDeploymentResponse" } } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -9100,8 +12814,8 @@ } } }, - "404": { - "description": "Not found", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9110,8 +12824,8 @@ } } }, - "409": { - "description": "Deployment already in progress", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -9120,8 +12834,8 @@ } } }, - "422": { - "description": "Settings orphaned", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -9130,8 +12844,8 @@ } } }, - "503": { - "description": "Service unavailable", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -9141,11 +12855,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/deployments/latest": { "get": { - "tags": ["Settings"], - "summary": "Get latest settings deployment", - "description": "Get the latest deployment for a specific settings entry.", + "tags": ["Deployments"], + "summary": "List latest deployments", + "description": "List the latest deployment for each flow in the project.", "parameters": [ { "schema": { @@ -9156,35 +12872,15 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" - }, - "required": true, - "name": "settingsId", - "in": "path" } ], "responses": { "200": { - "description": "Latest deployment (or null)", + "description": "Latest deployment per flow", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingsDeploymentResponse" + "$ref": "#/components/schemas/LatestDeploymentsByFlow" } } } @@ -9198,25 +12894,15 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deployments/{deploymentId}": { - "get": { - "tags": ["Settings"], - "summary": "Get settings deployment detail", - "description": "Get a specific deployment by ID, scoped to a settings entry.", + "/api/projects/{projectId}/runtimes/register": { + "post": { + "tags": ["Deployments"], + "summary": "Register runtime", + "description": "Register a server-side runtime container and get a presigned bundle URL.", "parameters": [ { "schema": { @@ -9227,44 +12913,68 @@ "required": true, "name": "projectId", "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRuntimeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Presigned bundle URL" }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/observe/ticket": { + "post": { + "tags": ["Observe"], + "summary": "Create SSE ticket", + "description": "Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership. An optional scope narrows the ticket to a subset of the project feed (e.g. one Observe session).", + "parameters": [ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" - }, - "required": true, - "name": "settingsId", - "in": "path" - }, - { - "schema": { - "type": "string", - "example": "dep_abc123" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "name": "deploymentId", + "name": "projectId", "in": "path" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObserveTicketRequest" + } + } + } + }, "responses": { "200": { - "description": "Deployment details", + "description": "Ticket generated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingsDeploymentDetailResponse" + "$ref": "#/components/schemas/ObserveTicketResponse" } } } @@ -9279,6 +12989,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -9292,11 +13012,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/previews": { + "/api/projects/{projectId}/observe/validate-ticket": { "post": { - "tags": ["Previews"], - "summary": "Create preview", - "description": "Create a new preview for a web flow settings entry. Bundles the flow and publishes to a unique token-based URL.", + "tags": ["Observe"], + "summary": "Validate ticket", + "description": "Internal endpoint for the Observer service to validate and consume a ticket.", "parameters": [ { "schema": { @@ -9307,34 +13027,24 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePreviewRequest" + "$ref": "#/components/schemas/ValidateTicketRequest" } } } }, "responses": { - "201": { - "description": "Preview created", + "200": { + "description": "Ticket payload", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePreviewResponse" + "$ref": "#/components/schemas/ValidateTicketResponse" } } } @@ -9350,7 +13060,7 @@ } }, "401": { - "description": "Unauthorized", + "description": "Invalid or expired ticket", "content": { "application/json": { "schema": { @@ -9368,43 +13078,55 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Quota exceeded", + } + } + } + }, + "/api/health": { + "get": { + "tags": ["System"], + "summary": "Health check", + "description": "Check the health of the API and its dependencies.", + "responses": { + "200": { + "description": "Health status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/HealthResponse" } } } - }, - "502": { - "description": "Bundle or upload failed", + } + } + } + }, + "/api/openapi.json": { + "get": { + "tags": ["System"], + "summary": "OpenAPI spec", + "description": "Return the OpenAPI 3.1 specification for this API.", + "responses": { + "200": { + "description": "OpenAPI document", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": {}, + "additionalProperties": {} } } } } } - }, - "get": { - "tags": ["Previews"], - "summary": "List previews", - "description": "List all previews for a flow, ordered by creation date descending.", + } + }, + "/ingest/v1/{projectId}": { + "post": { + "tags": ["Observer"], + "summary": "Event ingestion", + "description": "Ingest walkerOS events for a project. Served by the Observer service (port 3001).", "parameters": [ { "schema": { @@ -9415,41 +13137,113 @@ "required": true, "name": "projectId", "in": "path" + } + ], + "responses": { + "202": { + "description": "Accepted" + } + } + } + }, + "/stream/v1": { + "get": { + "tags": ["Observer"], + "summary": "SSE stream", + "description": "Server-Sent Events stream for real-time event observation. Requires a valid ticket. Served by the Observer service (port 3001).", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "One-time ticket from /api/projects/{projectId}/observe/ticket" + }, + "required": true, + "description": "One-time ticket from /api/projects/{projectId}/observe/ticket", + "name": "ticket", + "in": "query" }, { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "minLength": 1, + "description": "Project ID for scoped validation" }, "required": true, - "name": "flowId", - "in": "path" + "description": "Project ID for scoped validation", + "name": "project", + "in": "query" } ], "responses": { "200": { - "description": "List of previews", + "description": "SSE event stream" + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListPreviewsResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } - }, - "401": { - "description": "Unauthorized", + } + } + } + }, + "/health": { + "get": { + "tags": ["Observer"], + "summary": "Observer health", + "description": "Health check for the Observer service (port 3001).", + "responses": { + "200": { + "description": "Health status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": { + "status": { + "type": "string" + } + }, + "required": ["status"] + } + } + } + } + } + } + }, + "/api/feedback": { + "post": { + "tags": ["Feedback"], + "summary": "Submit user feedback", + "description": "Accepts free-form feedback from the walkerOS CLI, MCP, or a future in-app form. Public endpoint — no authentication required. The body `userId` is stored verbatim as a best-effort contact email and is not validated against the app users table.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Feedback stored", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackResponse" } } } }, - "404": { - "description": "Not found", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -9461,11 +13255,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}": { + "/api/projects/{projectId}/service-accounts": { "get": { - "tags": ["Previews"], - "summary": "Get preview", - "description": "Get a single preview by ID.", + "tags": ["Service Accounts"], + "summary": "List service accounts", + "description": "List service accounts for a project. Requires member role.", "parameters": [ { "schema": { @@ -9476,35 +13270,83 @@ "required": true, "name": "projectId", "in": "path" + } + ], + "responses": { + "200": { + "description": "List of service accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListServiceAccountsResponse" + } + } + } }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Service Accounts"], + "summary": "Create service account", + "description": "Create a service account and its first token. The raw token is returned once and cannot be retrieved again. Requires admin role.", + "parameters": [ { "schema": { "type": "string", - "pattern": "^prv_[a-z0-9]+$", - "example": "prv_abc123xyz456" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "name": "previewId", + "name": "projectId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountRequest" + } + } + } + }, "responses": { - "200": { - "description": "Preview details", + "201": { + "description": "Service account created (raw token shown once)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreviewResponse" + "$ref": "#/components/schemas/CreateServiceAccountResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -9519,8 +13361,18 @@ } } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -9530,11 +13382,13 @@ } } } - }, - "delete": { - "tags": ["Previews"], - "summary": "Delete preview", - "description": "Delete a preview and its S3 bundle. Requires member role.", + } + }, + "/api/projects/{projectId}/service-accounts/{serviceAccountId}": { + "get": { + "tags": ["Service Accounts"], + "summary": "Get service account", + "description": "Get a single service account by ID. Requires member role.", "parameters": [ { "schema": { @@ -9549,27 +13403,23 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$", - "example": "prv_abc123xyz456" + "example": "sa_abc123" }, "required": true, - "name": "previewId", + "name": "serviceAccountId", "in": "path" } ], "responses": { - "204": { - "description": "Preview deleted" + "200": { + "description": "Service account details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceAccountSummary" + } + } + } }, "401": { "description": "Unauthorized", @@ -9602,13 +13452,11 @@ } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/previews/{previewId}/grant": { - "post": { - "tags": ["Previews"], - "summary": "Mint preview activation grant", - "description": "Mint a fresh, origin-bound activation grant for an existing preview. Grants are origin-bound, so a preview needs one grant per host origin — re-mint whenever the target origin changes.", + }, + "patch": { + "tags": ["Service Accounts"], + "summary": "Update service account", + "description": "Update a service account's name, description, or role. Requires admin role.", "parameters": [ { "schema": { @@ -9623,21 +13471,10 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^prv_[a-z0-9]+$", - "example": "prv_abc123xyz456" + "example": "sa_abc123" }, "required": true, - "name": "previewId", + "name": "serviceAccountId", "in": "path" } ], @@ -9645,18 +13482,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MintGrantRequest" + "$ref": "#/components/schemas/UpdateServiceAccountRequest" } } } }, "responses": { "200": { - "description": "Grant minted", + "description": "Service account updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MintGrantResponse" + "$ref": "#/components/schemas/ServiceAccountSummary" } } } @@ -9700,9 +13537,50 @@ } } } + } + } + }, + "delete": { + "tags": ["Service Accounts"], + "summary": "Delete service account", + "description": "Soft-delete a service account and revoke all its tokens. Requires admin role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "sa_abc123" + }, + "required": true, + "name": "serviceAccountId", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Service account deleted" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, - "409": { - "description": "No active web deployment / host bundle not preview-enabled", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -9711,8 +13589,8 @@ } } }, - "429": { - "description": "Rate limited", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -9724,11 +13602,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions": { - "post": { - "tags": ["Observe Sessions"], - "summary": "Start observe session", - "description": "Start an Observe session for a flow. Validates the flow topology, inserts the row, and kicks off detached provisioning. Returns the row immediately as arming.", + "/api/projects/{projectId}/service-accounts/{serviceAccountId}/tokens": { + "get": { + "tags": ["Service Accounts"], + "summary": "List service account tokens", + "description": "List tokens for a service account. Returns summaries (no raw token values). Requires member role.", "parameters": [ { "schema": { @@ -9743,40 +13621,20 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "sa_abc123" }, "required": true, - "name": "flowId", + "name": "serviceAccountId", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateObserveSessionRequest" - } - } - } - }, "responses": { - "201": { - "description": "Observe session started", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveSessionResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "200": { + "description": "List of tokens", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListSaTokensResponse" } } } @@ -9810,74 +13668,60 @@ } } } - }, - "409": { - "description": "Flow topology not supported", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}": { - "get": { - "tags": ["Observe Sessions"], - "summary": "Get observe session", - "description": "Get an observe session: status, error message, config snapshot, web activation info, and the live server endpoint when live.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, + }, + "post": { + "tags": ["Service Accounts"], + "summary": "Create service account token", + "description": "Create a new token for a service account. The raw token is returned once and cannot be retrieved again. Requires admin role.", + "parameters": [ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "name": "flowId", + "name": "projectId", "in": "path" }, { "schema": { "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "example": "sa_abc123" }, "required": true, - "name": "sessionId", + "name": "serviceAccountId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSaTokenRequest" + } + } + } + }, "responses": { - "200": { - "description": "Observe session details", + "201": { + "description": "Token created (raw token shown once)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ObserveSessionResponse" + "$ref": "#/components/schemas/CreateSaTokenResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -9913,11 +13757,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/service-accounts/{serviceAccountId}/tokens/{tokenId}": { "delete": { - "tags": ["Observe Sessions"], - "summary": "End observe session", - "description": "End an observe session: tear down the container, revoke credentials, delete the web preview, delete the row. Idempotent.", + "tags": ["Service Accounts"], + "summary": "Revoke service account token", + "description": "Revoke a service account token. Requires admin role.", "parameters": [ { "schema": { @@ -9932,27 +13778,25 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "sa_abc123" }, "required": true, - "name": "flowId", + "name": "serviceAccountId", "in": "path" }, { "schema": { "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "example": "tok_abc123" }, "required": true, - "name": "sessionId", + "name": "tokenId", "in": "path" } ], "responses": { "204": { - "description": "Observe session ended" + "description": "Token revoked" }, "401": { "description": "Unauthorized", @@ -9987,11 +13831,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/journeys": { + "/api/projects/{projectId}/invitations": { "get": { - "tags": ["Observe Sessions"], - "summary": "Get observe session journeys", - "description": "Assemble the session's cross-runtime journeys server-side: fetch the raw records from the observer, derive the pipeline topology from the config snapshot, and run the pure assembler. Returns the journeys and per-platform loss gaps wrapped with the session scope.", + "tags": ["Invitations"], + "summary": "List invitations", + "description": "List invitations for a project. Defaults to pending; pass ?status=all for every status. Requires admin role.", "parameters": [ { "schema": { @@ -10006,31 +13850,28 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "enum": [ + "pending", + "accepted", + "declined", + "expired", + "cancelled", + "all" + ], + "example": "pending" }, - "required": true, - "name": "sessionId", - "in": "path" + "required": false, + "name": "status", + "in": "query" } ], "responses": { "200": { - "description": "Assembled journeys", + "description": "List of invitations", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ObserveSessionJourneysResponse" + "$ref": "#/components/schemas/ListInvitationsResponse" } } } @@ -10054,35 +13895,13 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "Observer unavailable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/journeys": { - "get": { - "tags": ["Observe Sessions"], - "summary": "Get flow journeys", - "description": "Resolve the flow's single active Observe session and assemble its cross-runtime journeys server-side. `observe_sessions.flow_id` is UNIQUE, so a flow has at most one session; when none is active the response carries `sessionId: null` with empty journeys rather than a 404. Narrow with `traceId` (one trace) and `limit` (page cap, most recent kept). This is the MCP `observe_journeys` REST contract.", + }, + "post": { + "tags": ["Invitations"], + "summary": "Create invitation", + "description": "Create an invitation and send the invite email. Requires admin role.", "parameters": [ { "schema": { @@ -10093,44 +13912,24 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1 - }, - "required": false, - "name": "traceId", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateInvitationRequest" + } + } + } + }, "responses": { - "200": { - "description": "Assembled flow journeys", + "201": { + "description": "Invitation created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FlowJourneysResponse" + "$ref": "#/components/schemas/CreateInvitationResponse" } } } @@ -10175,8 +13974,18 @@ } } }, - "502": { - "description": "Observer unavailable", + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Invitation limit reached", "content": { "application/json": { "schema": { @@ -10188,11 +13997,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/heartbeat": { - "post": { - "tags": ["Observe Sessions"], - "summary": "Heartbeat observe session", - "description": "Keep an observe session warm. The window posts this every 30s while open; a stale session is reaped by the janitor.", + "/api/projects/{projectId}/invitations/{inviteId}": { + "delete": { + "tags": ["Invitations"], + "summary": "Cancel invitation", + "description": "Cancel a pending invitation. Requires admin role.", "parameters": [ { "schema": { @@ -10207,37 +14016,29 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "example": "inv_abc123" }, "required": true, - "name": "sessionId", + "name": "inviteId", "in": "path" } ], "responses": { - "200": { - "description": "Heartbeat recorded", + "204": { + "description": "Invitation cancelled" + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ObserveSessionHeartbeatResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "401": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -10259,53 +14060,31 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/observe-sessions/{sessionId}/end": { - "post": { - "tags": ["Observe Sessions"], - "summary": "End observe session (beacon)", - "description": "The navigator.sendBeacon end target for page unload. Mirrors the DELETE end route because sendBeacon cannot send a DELETE. Idempotent.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, + "/api/invitations/{token}": { + "get": { + "tags": ["Invitations"], + "summary": "Preview invitation", + "description": "Preview invitation details. No authentication required — the token is the credential.", + "parameters": [ { "schema": { "type": "string", - "pattern": "^ses_[a-zA-Z0-9_-]+$", - "example": "ses_abc123xyz456" + "description": "Opaque invitation token (the credential).", + "example": "a1b2c3d4e5f6" }, "required": true, - "name": "sessionId", + "description": "Opaque invitation token (the credential).", + "name": "token", "in": "path" } ], "responses": { - "204": { - "description": "Observe session ended" - }, - "401": { - "description": "Unauthorized", + "200": { + "description": "Invitation preview", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/InvitationPreview" } } } @@ -10323,40 +14102,31 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/versions": { - "get": { - "tags": ["Versions"], - "summary": "List versions", - "description": "List all versions for a flow.", + "/api/invitations/{token}/accept": { + "post": { + "tags": ["Invitations"], + "summary": "Accept invitation", + "description": "Accept an invitation. Requires authentication; the authenticated user's email must match the invitation email.", "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "description": "Opaque invitation token (the credential).", + "example": "a1b2c3d4e5f6" }, "required": true, - "name": "flowId", + "description": "Opaque invitation token (the credential).", + "name": "token", "in": "path" } ], "responses": { "200": { - "description": "List of versions", + "description": "Invitation accepted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListVersionsResponse" + "$ref": "#/components/schemas/AcceptInvitationResponse" } } } @@ -10371,6 +14141,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -10380,60 +14160,51 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/flows/{flowId}/versions/{versionNumber}": { - "get": { - "tags": ["Versions"], - "summary": "Get version", - "description": "Get a specific version of a flow.", + "/api/invitations/{token}/decline": { + "post": { + "tags": ["Invitations"], + "summary": "Decline invitation", + "description": "Decline an invitation. No authentication required — the token is the credential.", "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "example": 1 + "description": "Opaque invitation token (the credential).", + "example": "a1b2c3d4e5f6" }, "required": true, - "name": "versionNumber", + "description": "Opaque invitation token (the credential).", + "name": "token", "in": "path" } ], "responses": { "200": { - "description": "Version details with content", + "description": "Invitation declined", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetVersionResponse" + "$ref": "#/components/schemas/DeclineInvitationResponse" } } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -10441,9 +14212,30 @@ } } } + } + } + } + }, + "/api/telemetry": { + "post": { + "tags": ["Telemetry"], + "summary": "Submit telemetry event", + "description": "Accept a single walkerOS v4 event from the CLI or MCP telemetry emitter. Public endpoint — no authentication. `source.type` is constrained to `cli` or `mcp`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TelemetryEvent" + } + } + } + }, + "responses": { + "204": { + "description": "Telemetry event accepted" }, - "404": { - "description": "Not found", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -10455,11 +14247,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/versions/{versionNumber}/restore": { - "post": { - "tags": ["Versions"], - "summary": "Restore version", - "description": "Restore a flow to a specific version. Creates a new version snapshot. Requires member role.", + "/api/projects/{projectId}/billing": { + "get": { + "tags": ["Billing"], + "summary": "Get billing details", + "description": "Get billing details for a project, or null when none are set. Requires member role.", "parameters": [ { "schema": { @@ -10470,35 +14262,22 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "example": 1 - }, - "required": true, - "name": "versionNumber", - "in": "path" } ], "responses": { "200": { - "description": "Flow restored", + "description": "Billing details (or null when unset)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "anyOf": [ + { + "$ref": "#/components/schemas/BillingDetailsResponse" + }, + { + "type": "null" + } + ] } } } @@ -10534,56 +14313,39 @@ } } } - } - }, - "/api/tokens": { - "get": { - "tags": ["Tokens"], - "summary": "List my tokens", - "description": "List all API tokens for the authenticated user. Returns summaries (no raw token values).", - "responses": { - "200": { - "description": "List of tokens", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListApiTokensResponse" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } }, - "post": { - "tags": ["Tokens"], - "summary": "Create token", - "description": "Create a new API token. The raw token is returned once and cannot be retrieved again.", + "put": { + "tags": ["Billing"], + "summary": "Upsert billing details", + "description": "Create or update billing details for a project. Requires owner role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiTokenRequest" + "$ref": "#/components/schemas/UpsertBillingDetailsRequest" } } } }, "responses": { - "201": { - "description": "Token created (raw token shown once)", + "200": { + "description": "Billing details saved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateApiTokenResponse" + "$ref": "#/components/schemas/BillingDetailsResponse" } } } @@ -10608,8 +14370,8 @@ } } }, - "409": { - "description": "Conflict", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -10617,33 +14379,9 @@ } } } - } - } - } - }, - "/api/tokens/{tokenId}": { - "delete": { - "tags": ["Tokens"], - "summary": "Revoke token", - "description": "Revoke an API token (soft delete via revokedAt timestamp).", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^tok_[a-zA-Z0-9_-]+$", - "example": "tok_a1b2c3d4" - }, - "required": true, - "name": "tokenId", - "in": "path" - } - ], - "responses": { - "204": { - "description": "Token revoked" }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -10655,11 +14393,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/bundle": { - "post": { - "tags": ["Bundle"], - "summary": "Bundle flow", - "description": "Bundle a flow using CLI. Returns bundleId (content-hash). Use ?output=download to redirect to presigned S3 URL.", + "/api/projects/{projectId}/deployments/{deploymentId}": { + "get": { + "tags": ["Deployments"], + "summary": "Get deployment detail", + "description": "Get deployment detail. Accepts a dep_ID or a slug. Requires member role.", "parameters": [ { "schema": { @@ -10674,50 +14412,24 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "dep_abc123" }, "required": true, - "name": "flowId", + "name": "deploymentId", "in": "path" - }, - { - "schema": { - "type": "string", - "example": "my-web-flow", - "description": "Named flow to bundle (required for multi-settings flows)" - }, - "required": false, - "description": "Named flow to bundle (required for multi-settings flows)", - "name": "flow", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["download"], - "description": "Set to \"download\" to redirect to the bundle file" - }, - "required": false, - "description": "Set to \"download\" to redirect to the bundle file", - "name": "output", - "in": "query" } ], "responses": { "200": { - "description": "Bundle result", + "description": "Deployment detail", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BundleResponse" + "$ref": "#/components/schemas/DeploymentDetailResponse" } } } }, - "302": { - "description": "Redirect to presigned bundle URL (when output=download)" - }, "401": { "description": "Unauthorized", "content": { @@ -10728,6 +14440,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -10737,15 +14459,23 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/simulate": { - "post": { - "tags": ["Simulate"], - "summary": "Simulate a flow step", - "description": "Execute a simulation against a pre-built bundle. Requires bundleId from the bundle endpoint.", + }, + "patch": { + "tags": ["Deployments"], + "summary": "Update deployment", + "description": "Update a deployment's label, or stop/resume it. Stop and resume require admin role; label updates require member role.", "parameters": [ { "schema": { @@ -10760,11 +14490,10 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "dep_abc123" }, "required": true, - "name": "flowId", + "name": "deploymentId", "in": "path" } ], @@ -10772,18 +14501,38 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SimulateRequest" + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 255 + }, + "action": { + "type": "string", + "enum": ["stop", "resume"] + } + } } } } }, "responses": { "200": { - "description": "Simulation result", + "description": "Deployment updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SimulateResponse" + "$ref": "#/components/schemas/UpdateDeploymentResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10798,6 +14547,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -10808,8 +14567,8 @@ } } }, - "502": { - "description": "Simulation container error", + "409": { + "description": "Deployment state changed concurrently", "content": { "application/json": { "schema": { @@ -10819,13 +14578,11 @@ } } } - } - }, - "/api/projects/{projectId}/deployments": { - "get": { + }, + "delete": { "tags": ["Deployments"], - "summary": "List deployments", - "description": "List deployments for a project. Supports filtering by status, type, origin, and flowId, plus pagination.", + "summary": "Delete deployment", + "description": "Tear down and soft-delete a deployment. Idempotent. Requires owner role.", "parameters": [ { "schema": { @@ -10840,106 +14597,29 @@ { "schema": { "type": "string", - "enum": [ - "idle", - "deploying", - "published", - "active", - "stopped", - "failed" - ] - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["web", "server"] - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["cloud", "self-hosted"] - }, - "required": false, - "name": "origin", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": false, - "name": "flowId", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["created_at", "updated_at"] - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["asc", "desc"] - }, - "required": false, - "name": "order", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" - }, - { - "schema": { - "type": "string" + "example": "dep_abc123" }, - "required": false, - "name": "cursor", - "in": "query" + "required": true, + "name": "deploymentId", + "in": "path" } ], "responses": { - "200": { - "description": "List of deployments", + "204": { + "description": "Deployment deleted (or already absent)" + }, + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListDeploymentsResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -10948,8 +14628,8 @@ } } }, - "401": { - "description": "Unauthorized", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -10959,11 +14639,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/deployments/{deploymentId}/publish": { "post": { "tags": ["Deployments"], - "summary": "Create deployment", - "description": "Create a new deployment slot. Supports an Idempotency-Key header — a repeated key returns the original deployment id with status `already_created`. Requires member role.", + "summary": "Publish deployment version", + "description": "Push a new version to a deployment, either from an existing flow setting or from a direct config upload. Bundles in-process and transitions the deployment to `deploying`. Requires member role.", "parameters": [ { "schema": { @@ -10978,10 +14660,19 @@ { "schema": { "type": "string", - "description": "Optional client key to make creation idempotent." + "example": "dep_abc123" + }, + "required": true, + "name": "deploymentId", + "in": "path" + }, + { + "schema": { + "type": "string", + "description": "Optional client key to make publishing idempotent." }, "required": false, - "description": "Optional client key to make creation idempotent.", + "description": "Optional client key to make publishing idempotent.", "name": "idempotency-key", "in": "header" } @@ -10990,36 +14681,52 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["web", "server"], - "example": "web" - }, - "label": { - "type": "string", - "maxLength": 255 - }, - "flowId": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": ["flow"] + }, + "flowId": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$" + }, + "flowSettingsName": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "required": ["source", "flowId", "flowSettingsName"] }, - "flowSettingsId": { - "type": "string" + { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": ["config"] + }, + "config": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["source", "config"] } - }, - "required": ["type"] + ] } } } }, "responses": { "201": { - "description": "Deployment created", + "description": "Version published (bundling/deploying)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateDeploymentResponse" + "$ref": "#/components/schemas/PublishVersionResponse" } } } @@ -11054,8 +14761,8 @@ } } }, - "409": { - "description": "Conflict", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -11064,8 +14771,8 @@ } } }, - "429": { - "description": "Rate limited", + "409": { + "description": "Publish already in progress", "content": { "application/json": { "schema": { @@ -11073,40 +14780,19 @@ } } } - } - } - } - }, - "/api/projects/{projectId}/deployments/latest": { - "get": { - "tags": ["Deployments"], - "summary": "List latest deployments", - "description": "List the latest deployment for each flow in the project.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Latest deployment per flow", + }, + "429": { + "description": "Rate limited or concurrent deploy limit", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LatestDeploymentsByFlow" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "401": { - "description": "Unauthorized", + "503": { + "description": "Build service unavailable", "content": { "application/json": { "schema": { @@ -11118,11 +14804,11 @@ } } }, - "/api/projects/{projectId}/runtimes/register": { - "post": { + "/api/projects/{projectId}/deployments/{deploymentId}/stream": { + "get": { "tags": ["Deployments"], - "summary": "Register runtime", - "description": "Register a server-side runtime container and get a presigned bundle URL.", + "summary": "Stream deployment status (SSE)", + "description": "Server-Sent Events (`text/event-stream`) stream of a deployment's live status. Emits named events: `status` (a snapshot payload, schema below), `done` (terminal, no body), and `timeout`. The CLI consumes this with a raw fetch while waiting for a deploy to finish. Requires member role. The schema documents the JSON `data:` of a `status` event; `errorCode`/`errorMessage` carry the persisted, redacted classification of a failed deploy.", "parameters": [ { "schema": { @@ -11133,68 +14819,24 @@ "required": true, "name": "projectId", "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRuntimeRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Presigned bundle URL" }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/projects/{projectId}/observe/ticket": { - "post": { - "tags": ["Observe"], - "summary": "Create SSE ticket", - "description": "Generate a one-time ticket for authenticating an SSE connection to the Observer service. Requires project membership. An optional scope narrows the ticket to a subset of the project feed (e.g. one Observe session).", - "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" + "example": "dep_abc123" }, "required": true, - "name": "projectId", + "name": "deploymentId", "in": "path" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveTicketRequest" - } - } - } - }, "responses": { "200": { - "description": "Ticket generated", + "description": "SSE stream; `status` event payload shape documented here.", "content": { - "application/json": { + "text/event-stream": { "schema": { - "$ref": "#/components/schemas/ObserveTicketResponse" + "$ref": "#/components/schemas/DeploymentStreamStatusEvent" } } } @@ -11209,16 +14851,6 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -11232,11 +14864,11 @@ } } }, - "/api/projects/{projectId}/observe/validate-ticket": { - "post": { - "tags": ["Observe"], - "summary": "Validate ticket", - "description": "Internal endpoint for the Observer service to validate and consume a ticket.", + "/api/projects/{projectId}/deployments/{deploymentId}/versions": { + "get": { + "tags": ["Deployments"], + "summary": "List deployment versions", + "description": "List the version history for a deployment, paginated. Requires member role.", "parameters": [ { "schema": { @@ -11247,94 +14879,83 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "example": "dep_abc123" + }, + "required": true, + "name": "deploymentId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": ["integer", "null"], + "minimum": 0 + }, + "required": false, + "name": "offset", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateTicketRequest" - } - } - } - }, "responses": { "200": { - "description": "Ticket payload", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateTicketResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Version history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListDeploymentVersionsResponse" } } } }, "401": { - "description": "Invalid or expired ticket", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "403": { - "description": "Forbidden", + "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } - } - } - } - } - }, - "/api/health": { - "get": { - "tags": ["System"], - "summary": "Health check", - "description": "Check the health of the API and its dependencies.", - "responses": { - "200": { - "description": "Health status", + } + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } - } - } - } - }, - "/api/openapi.json": { - "get": { - "tags": ["System"], - "summary": "OpenAPI spec", - "description": "Return the OpenAPI 3.1 specification for this API.", - "responses": { - "200": { - "description": "OpenAPI document", + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "type": "object", - "properties": {}, - "additionalProperties": {} + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11342,11 +14963,11 @@ } } }, - "/ingest/v1/{projectId}": { - "post": { - "tags": ["Observer"], - "summary": "Event ingestion", - "description": "Ingest walkerOS events for a project. Served by the Observer service (port 3001).", + "/api/projects/{projectId}/flows/{flowId}/releases": { + "get": { + "tags": ["Deployments"], + "summary": "List flow releases", + "description": "List the release history for a flow across all of its deployment lineages, newest first, paginated. Each entry is a deployed version joined to its parent deployment (slug and type). `rationale=true` joins each row's stored rationale summary on, which requires the `hub` feature; without it the `rationale` key is absent from every row rather than null, and no feature beyond member role is needed. Requires member role.", "parameters": [ { "schema": { @@ -11357,47 +14978,56 @@ "required": true, "name": "projectId", "in": "path" - } - ], - "responses": { - "202": { - "description": "Accepted" - } - } - } - }, - "/stream/v1": { - "get": { - "tags": ["Observer"], - "summary": "SSE stream", - "description": "Server-Sent Events stream for real-time event observation. Requires a valid ticket. Served by the Observer service (port 3001).", - "parameters": [ + }, { "schema": { "type": "string", - "minLength": 1, - "description": "One-time ticket from /api/projects/{projectId}/observe/ticket" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "description": "One-time ticket from /api/projects/{projectId}/observe/ticket", - "name": "ticket", + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": ["integer", "null"], + "minimum": 0 + }, + "required": false, + "name": "offset", "in": "query" }, { "schema": { "type": "string", - "minLength": 1, - "description": "Project ID for scoped validation" + "enum": ["true", "false"] }, - "required": true, - "description": "Project ID for scoped validation", - "name": "project", + "required": false, + "name": "rationale", "in": "query" } ], "responses": { "200": { - "description": "SSE event stream" + "description": "Flow release history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListFlowReleasesResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -11408,62 +15038,29 @@ } } } - } - } - } - }, - "/health": { - "get": { - "tags": ["Observer"], - "summary": "Observer health", - "description": "Health check for the Observer service (port 3001).", - "responses": { - "200": { - "description": "Health status", + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - } - }, - "required": ["status"] + "$ref": "#/components/schemas/ErrorResponse" } } } - } - } - } - }, - "/api/feedback": { - "post": { - "tags": ["Feedback"], - "summary": "Submit user feedback", - "description": "Accepts free-form feedback from the walkerOS CLI, MCP, or a future in-app form. Public endpoint — no authentication required. The body `userId` is stored verbatim as a best-effort contact email and is not validated against the app users table.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FeedbackRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Feedback stored", + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeedbackResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "400": { - "description": "Validation error", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -11475,11 +15072,11 @@ } } }, - "/api/projects/{projectId}/service-accounts": { + "/api/projects/{projectId}/flows/{flowId}/releases/{versionId}": { "get": { - "tags": ["Service Accounts"], - "summary": "List service accounts", - "description": "List service accounts for a project. Requires member role.", + "tags": ["Deployments"], + "summary": "Read one release in full", + "description": "One release of this flow with its rationale and its diff. The path segment is either the spine version id (`ver_...`) or the flow-unique spine number, and the route decides which it was, so a caller holding only the number needs no lookup first. The diff is computed server-side from the two stored snapshots and is never accepted from a caller; its predecessor is the next LOWER spine number, not the previous row by time, because spine rows are reused across redeploys of identical content. `diff.text` is rendered from masked content, so an empty string can still mean the releases differ inside an inline secret: `diff.contentIdentical`, compared over the unmasked hashes, is the trustworthy answer. `diff` is null for the flow's oldest release. An unknown address, a sibling flow's version, and an autosave revision all answer 404 alike. Requires member role and the `hub` feature.", "parameters": [ { "schema": { @@ -11490,15 +15087,47 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^(ver_[a-zA-Z0-9_-]+|[1-9][0-9]*)$", + "example": "ver_a1b2c3d4", + "description": "Spine version id of the release (ver_...) or its spine number" + }, + "required": true, + "description": "Spine version id of the release (ver_...) or its spine number", + "name": "versionId", + "in": "path" } ], "responses": { "200": { - "description": "List of service accounts", + "description": "The release, its rationale, and its diff", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListServiceAccountsResponse" + "$ref": "#/components/schemas/ReleaseDetailResponse" + } + } + } + }, + "400": { + "description": "Invalid release reference", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11522,13 +15151,35 @@ } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - }, - "post": { - "tags": ["Service Accounts"], - "summary": "Create service account", - "description": "Create a service account and its first token. The raw token is returned once and cannot be retrieved again. Requires admin role.", + } + }, + "/api/projects/{projectId}/flows/{flowId}/releases/{versionId}/content": { + "get": { + "tags": ["Deployments"], + "summary": "Read a release snapshot", + "description": "The flow config one release of this flow froze, addressed by its spine version id. This is the only route that serves a release snapshot: the positional `/versions/{versionNumber}` route numbers the autosave revisions, a disjoint set of rows, so a release number handed to it addresses an unrelated revision or nothing. Inline secret literals are masked. An unknown id, a sibling flow's version, and an autosave revision all answer 404 alike. Requires member role and the `hub` feature.", "parameters": [ { "schema": { @@ -11539,30 +15190,43 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4", + "description": "Spine version ID of the release (ver_...)" + }, + "required": true, + "description": "Spine version ID of the release (ver_...)", + "name": "versionId", + "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateServiceAccountRequest" - } - } - } - }, "responses": { - "201": { - "description": "Service account created (raw token shown once)", + "200": { + "description": "The release snapshot", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateServiceAccountResponse" + "$ref": "#/components/schemas/ReleaseContentResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid version id", "content": { "application/json": { "schema": { @@ -11581,8 +15245,18 @@ } } }, - "403": { - "description": "Forbidden", + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -11591,8 +15265,8 @@ } } }, - "409": { - "description": "Conflict", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -11604,11 +15278,11 @@ } } }, - "/api/projects/{projectId}/service-accounts/{serviceAccountId}": { + "/api/projects/{projectId}/flows/{flowId}/releases/annotations": { "get": { - "tags": ["Service Accounts"], - "summary": "Get service account", - "description": "Get a single service account by ID. Requires member role.", + "tags": ["Deployments"], + "summary": "List release rationale", + "description": "Read the rationale attached to the given releases of a flow. `versionIds` is a comma-separated list of spine version ids (at most 100), all of which must belong to this flow. Releases without rationale are absent from the response. Requires member role.", "parameters": [ { "schema": { @@ -11623,20 +15297,40 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "example": "ver_a1b2c3d4,ver_e5f6g7h8" + }, + "required": true, + "name": "versionIds", + "in": "query" } ], "responses": { "200": { - "description": "Service account details", + "description": "Rationale for the requested releases", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceAccountSummary" + "$ref": "#/components/schemas/ListVersionAnnotationsResponse" + } + } + } + }, + "400": { + "description": "Invalid version ids", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11670,13 +15364,23 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, - "patch": { - "tags": ["Service Accounts"], - "summary": "Update service account", - "description": "Update a service account's name, description, or role. Requires admin role.", + "put": { + "tags": ["Deployments"], + "summary": "Write release rationale", + "description": "Create or update the human rationale for one release of this flow. A null `humanText` clears it. The generated summary is machine-written and cannot be set through this route. The target must be a numbered release version of this flow, not an autosave revision. Requires member role.", "parameters": [ { "schema": { @@ -11691,10 +15395,11 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", "in": "path" } ], @@ -11702,24 +15407,36 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateServiceAccountRequest" + "type": "object", + "properties": { + "versionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + }, + "humanText": { + "type": ["string", "null"], + "maxLength": 4000 + } + }, + "required": ["versionId", "humanText"] } } } }, "responses": { "200": { - "description": "Service account updated", + "description": "The stored rationale", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceAccountSummary" + "$ref": "#/components/schemas/UpsertVersionAnnotationResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body, or the target is not a release", "content": { "application/json": { "schema": { @@ -11757,13 +15474,25 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - }, - "delete": { - "tags": ["Service Accounts"], - "summary": "Delete service account", - "description": "Soft-delete a service account and revoke all its tokens. Requires admin role.", + } + }, + "/api/projects/{projectId}/flows/{flowId}/threads": { + "get": { + "tags": ["Deployments"], + "summary": "List discussion threads on a flow", + "description": "Threads anchored to things in this flow, most recently active first. `anchorType` and `anchorKey` narrow to one anchor and are only meaningful together. `includeMessages=true` attaches the messages; otherwise each thread carries `messageCount` alone. Attaching them holds the page to a smaller ceiling than the lean index and caps each thread at its newest 50 messages, with `hasMoreMessages` set when a thread holds more. Because that ceiling is below the `limit` a caller may pass, the response carries `hasMoreThreads`: a full page is not proof of a complete list. A resolved thread carries the release that settled it, and `resolvedByVersionId` is null once that release is gone, which is what `anchorLabel` is kept for. Requires member role.", "parameters": [ { "schema": { @@ -11778,16 +15507,81 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["step", "entity_action", "release", "contract", "tag"], + "example": "release" + }, + "required": false, + "name": "anchorType", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "ver_a1b2c3d4" + }, + "required": false, + "name": "anchorKey", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["open", "resolved"], + "example": "open" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false, + "name": "includeMessages", + "in": "query" + }, + { + "schema": { + "type": "integer", + "example": 50 + }, + "required": false, + "name": "limit", + "in": "query" } ], "responses": { - "204": { - "description": "Service account deleted" + "200": { + "description": "Threads on this flow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListHubThreadsResponse" + } + } + } + }, + "400": { + "description": "Invalid query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -11818,15 +15612,23 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - } - }, - "/api/projects/{projectId}/service-accounts/{serviceAccountId}/tokens": { - "get": { - "tags": ["Service Accounts"], - "summary": "List service account tokens", - "description": "List tokens for a service account. Returns summaries (no raw token values). Requires member role.", + }, + "post": { + "tags": ["Deployments"], + "summary": "Open a discussion thread", + "description": "Open a thread on one anchor, with its first message. A thread never exists empty, so `text` is required and may not be blank. A `release` anchor must name a numbered release of this flow: the server verifies it and derives the label, so `anchorLabel` is ignored for that type. For any other anchor type `anchorLabel` is a display snapshot of the anchor as it reads now, stored so a later rename leaves the thread readable instead of unlabeled, and defaults to the anchor key. Requires member role.", "parameters": [ { "schema": { @@ -11841,20 +15643,69 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "anchorType": { + "type": "string", + "enum": [ + "step", + "entity_action", + "release", + "contract", + "tag" + ], + "example": "release" + }, + "anchorKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "anchorLabel": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": ["anchorType", "anchorKey", "text"] + } + } + } + }, "responses": { - "200": { - "description": "List of tokens", + "201": { + "description": "The opened thread, with its first message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HubThreadResponse" + } + } + } + }, + "400": { + "description": "Invalid body, or the anchor is not a release", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSaTokensResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11880,7 +15731,17 @@ } }, "404": { - "description": "Not found", + "description": "Flow not found, or the anchor names no release of it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -11890,11 +15751,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/flows/{flowId}/threads/{threadId}/messages": { "post": { - "tags": ["Service Accounts"], - "summary": "Create service account token", - "description": "Create a new token for a service account. The raw token is returned once and cannot be retrieved again. Requires admin role.", + "tags": ["Deployments"], + "summary": "Reply to a thread", + "description": "Append a message to a thread. `text` may not be blank: a message cannot be cleared, so empty is invalid rather than a way to erase one. Replying does not reopen a resolved thread: the resolve link is a statement about a release and is never retracted implicitly. Requires member role.", "parameters": [ { "schema": { @@ -11909,10 +15772,23 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^thr_[a-z0-9]+$", + "example": "thr_a1b2c3d4", + "description": "Thread ID (thr_...)" + }, + "required": true, + "description": "Thread ID (thr_...)", + "name": "threadId", "in": "path" } ], @@ -11920,24 +15796,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSaTokenRequest" + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": ["text"] } } } }, "responses": { "201": { - "description": "Token created (raw token shown once)", + "description": "The thread, with the new message", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSaTokenResponse" + "$ref": "#/components/schemas/HubThreadResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body", "content": { "application/json": { "schema": { @@ -11975,15 +15859,25 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/service-accounts/{serviceAccountId}/tokens/{tokenId}": { - "delete": { - "tags": ["Service Accounts"], - "summary": "Revoke service account token", - "description": "Revoke a service account token. Requires admin role.", + "/api/projects/{projectId}/flows/{flowId}/threads/{threadId}": { + "patch": { + "tags": ["Deployments"], + "summary": "Resolve or reopen a thread", + "description": "Resolving records the release that settled the thread: pass `resolvedByVersionId`, or omit it to record the flow’s newest release. The target must be a numbered release of this flow, never an autosave revision. Reopening drops the link. Requires member role.", "parameters": [ { "schema": { @@ -11998,25 +15892,68 @@ { "schema": { "type": "string", - "example": "sa_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "serviceAccountId", + "name": "flowId", "in": "path" }, { "schema": { "type": "string", - "example": "tok_abc123" + "pattern": "^thr_[a-z0-9]+$", + "example": "thr_a1b2c3d4", + "description": "Thread ID (thr_...)" }, "required": true, - "name": "tokenId", + "description": "Thread ID (thr_...)", + "name": "threadId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["open", "resolved"], + "example": "open" + }, + "resolvedByVersionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + } + }, + "required": ["status"] + } + } + } + }, "responses": { - "204": { - "description": "Token revoked" + "200": { + "description": "The updated thread", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HubThreadResponse" + } + } + } + }, + "400": { + "description": "Invalid body, or the target is not a release", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -12047,15 +15984,25 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/invitations": { + "/api/projects/{projectId}/knowledge": { "get": { - "tags": ["Invitations"], - "summary": "List invitations", - "description": "List invitations for a project. Defaults to pending; pass ?status=all for every status. Requires admin role.", + "tags": ["Projects"], + "summary": "List knowledge captured in this project", + "description": "What people wrote on the frames of a page, most recently active first. Two kinds come back together and `kind` separates them: a `thread` carries its text in messages, a `description` carries one body and cannot be replied to. `pageKey` narrows to a whole page, resolved server-side to every frame that page holds at any depth; `frameId` narrows to one frame; `markId` narrows to one mark within it and is refused without `frameId`, since a mark id alone addresses nothing. `includeMessages=true` attaches message bodies and holds the page to a much smaller ceiling, so `hasMoreEntries` is what separates a complete answer from a truncated one. `validity` says when an entry was true and `freshness` compares that against the flow’s newest release; neither is a verdict. Requires member role.", "parameters": [ { "schema": { @@ -12070,28 +16017,67 @@ { "schema": { "type": "string", - "enum": [ - "pending", - "accepted", - "declined", - "expired", - "cancelled", - "all" - ], - "example": "pending" + "example": "https://shop.example/checkout" }, "required": false, - "name": "status", + "name": "pageKey", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" + }, + "required": false, + "name": "frameId", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "mark_add_to_cart" + }, + "required": false, + "name": "markId", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false, + "name": "includeMessages", + "in": "query" + }, + { + "schema": { + "type": "integer", + "example": 50 + }, + "required": false, + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "List of invitations", + "description": "Knowledge in this project", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListInvitationsResponse" + "$ref": "#/components/schemas/ListKnowledgeResponse" + } + } + } + }, + "400": { + "description": "Invalid query, or a mark filter with no frame", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12115,13 +16101,33 @@ } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, "post": { - "tags": ["Invitations"], - "summary": "Create invitation", - "description": "Create an invitation and send the invite email. Requires admin role.", + "tags": ["Projects"], + "summary": "Open a thread on a mark or a frame", + "description": "Open a thread on one mark of one frame, or on the frame itself with `anchorType` `page` and no `markId`, with its first message. A thread never exists empty, so `text` is required and may not be blank. `clientThreadId` and `clientMessageId` are minted by the client at compose time and are what make a replay idempotent: repeating a known `clientThreadId` hands back the existing thread and writes nothing, so an offline queue can drain repeatedly without duplicating what a person wrote once. `flowId` binds the capture to a flow or is explicitly null; a flow this project cannot see answers 404, never 403. A frame this project does not hold answers 404 with `FRAME_NOT_FOUND`, which a draining client waits on and retries, because the frame’s own write may not have landed yet. The server decides the composed anchor key, the born release, the author and the source: a client cannot assert any of them. Requires member role.", "parameters": [ { "schema": { @@ -12138,24 +16144,82 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateInvitationRequest" + "type": "object", + "properties": { + "anchorType": { + "type": "string", + "enum": ["tag", "page"], + "example": "tag" + }, + "frameId": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" + }, + "markId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "anchorLabel": { + "type": "string", + "maxLength": 255 + }, + "flowId": { + "type": ["string", "null"], + "minLength": 1 + }, + "subjectKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "spatial": { + "$ref": "#/components/schemas/KnowledgeSpatial" + }, + "clientThreadId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "ct_7f3a91" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "clientMessageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "ct_7f3a91" + } + }, + "required": [ + "anchorType", + "frameId", + "flowId", + "clientThreadId", + "text", + "clientMessageId" + ] } } } }, "responses": { "201": { - "description": "Invitation created", + "description": "The thread, with its first message", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateInvitationResponse" + "$ref": "#/components/schemas/KnowledgeThreadResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body", "content": { "application/json": { "schema": { @@ -12185,17 +16249,7 @@ } }, "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "409": { - "description": "Conflict", + "description": "The named flow or frame is not in this project", "content": { "application/json": { "schema": { @@ -12205,7 +16259,7 @@ } }, "429": { - "description": "Invitation limit reached", + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -12217,11 +16271,11 @@ } } }, - "/api/projects/{projectId}/invitations/{inviteId}": { - "delete": { - "tags": ["Invitations"], - "summary": "Cancel invitation", - "description": "Cancel a pending invitation. Requires admin role.", + "/api/projects/{projectId}/knowledge/{threadId}/messages": { + "post": { + "tags": ["Projects"], + "summary": "Reply to a knowledge thread", + "description": "Append a message to a thread and get the whole thread back, so a surface renders the new exchange without a second read. `text` may not be blank: a message cannot be cleared, so empty is invalid rather than a way to erase one. Repeating a `clientMessageId` already on the thread appends nothing and leaves `updatedAt` alone, so a retrying drain never keeps bumping a thread to the top of every list. A description has no conversation and cannot be replied to; addressing one answers 404. Requires member role.", "parameters": [ { "schema": { @@ -12236,29 +16290,52 @@ { "schema": { "type": "string", - "example": "inv_abc123" + "pattern": "^thr_[a-z0-9]+$", + "example": "thr_a1b2c3d4", + "description": "Thread ID (thr_...)" }, "required": true, - "name": "inviteId", + "description": "Thread ID (thr_...)", + "name": "threadId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "clientMessageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "ct_7f3a91" + } + }, + "required": ["text", "clientMessageId"] + } + } + } + }, "responses": { - "204": { - "description": "Invitation cancelled" - }, - "401": { - "description": "Unauthorized", + "201": { + "description": "The thread, with the new message", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/KnowledgeThreadResponse" } } } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid body", "content": { "application/json": { "schema": { @@ -12267,8 +16344,8 @@ } } }, - "404": { - "description": "Not found", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -12276,35 +16353,13 @@ } } } - } - } - } - }, - "/api/invitations/{token}": { - "get": { - "tags": ["Invitations"], - "summary": "Preview invitation", - "description": "Preview invitation details. No authentication required — the token is the credential.", - "parameters": [ - { - "schema": { - "type": "string", - "description": "Opaque invitation token (the credential).", - "example": "a1b2c3d4e5f6" - }, - "required": true, - "description": "Opaque invitation token (the credential).", - "name": "token", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Invitation preview", + }, + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvitationPreview" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12318,35 +16373,102 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/invitations/{token}/accept": { - "post": { - "tags": ["Invitations"], - "summary": "Accept invitation", - "description": "Accept an invitation. Requires authentication; the authenticated user's email must match the invitation email.", + "/api/projects/{projectId}/knowledge/description": { + "put": { + "tags": ["Projects"], + "summary": "Write the description of a mark or a frame", + "description": "Write the one description of one anchor, a mark or the frame itself, replacing whatever it said before. There is no id to mint: the anchor is the key, so a replayed write lands on the same row by construction, which is why this is a PUT. An empty `body` is refused rather than stored, so a drain that arrives with nothing to say can never erase what a person wrote. The response is 200 whether the description was opened or replaced. Requires member role.", "parameters": [ { "schema": { "type": "string", - "description": "Opaque invitation token (the credential).", - "example": "a1b2c3d4e5f6" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "description": "Opaque invitation token (the credential).", - "name": "token", + "name": "projectId", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "anchorType": { + "type": "string", + "enum": ["tag", "page"], + "example": "tag" + }, + "frameId": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "example": "frm_V1StGXR8Z5jdHi6BmyT7K" + }, + "markId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "anchorLabel": { + "type": "string", + "maxLength": 255 + }, + "flowId": { + "type": ["string", "null"], + "minLength": 1 + }, + "subjectKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "spatial": { + "$ref": "#/components/schemas/KnowledgeSpatial" + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": ["anchorType", "frameId", "flowId", "body"] + } + } + } + }, "responses": { "200": { - "description": "Invitation accepted", + "description": "The stored description", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AcceptInvitationResponse" + "$ref": "#/components/schemas/KnowledgeDescriptionResponse" + } + } + } + }, + "400": { + "description": "Invalid body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12372,7 +16494,7 @@ } }, "404": { - "description": "Not found", + "description": "The named flow or frame is not in this project", "content": { "application/json": { "schema": { @@ -12381,8 +16503,8 @@ } } }, - "409": { - "description": "Conflict", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -12394,37 +16516,62 @@ } } }, - "/api/invitations/{token}/decline": { - "post": { - "tags": ["Invitations"], - "summary": "Decline invitation", - "description": "Decline an invitation. No authentication required — the token is the credential.", + "/api/projects/{projectId}/frames": { + "get": { + "tags": ["Projects"], + "summary": "List the frames of a page, or of the whole project", + "description": "A frame is a named rectangle with marks inside it, the spatial unit of a measurement plan. Naming a `pageKey` returns that page’s frames at any depth, marks and all, newest updated first: the walk starts at the page’s top-level frames and descends containment, so a child is reachable through its parent rather than by carrying a page of its own. Naming no page returns every live frame of the project WITHOUT its marks, which is what makes that read cheap enough to answer \"what does this project have\": the marks are the bulk of a frame and a listing never renders them. That lean read asks nothing about containment, so a frame whose parent cannot be resolved still appears. `include=marks` asks that project-wide read for the marks anyway, for a surface that spans pages and cannot fetch a page at a time; it is a second, heavier read of the same rows, taken after the lean list has already painted, and omitting it returns exactly the lean rows. It says nothing to the page read, which carries marks either way. Requires member role.", "parameters": [ { "schema": { "type": "string", - "description": "Opaque invitation token (the credential).", - "example": "a1b2c3d4e5f6" + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" }, "required": true, - "description": "Opaque invitation token (the credential).", - "name": "token", + "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "required": false, + "name": "pageKey", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["marks"] + }, + "required": false, + "name": "include", + "in": "query" } ], "responses": { "200": { - "description": "Invitation declined", + "description": "The page’s frames with their marks, or the project’s frames without them", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeclineInvitationResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/FrameListResponse" + }, + { + "$ref": "#/components/schemas/FrameLeanListResponse" + } + ] } } } }, - "404": { - "description": "Not found", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -12432,30 +16579,39 @@ } } } - } - } - } - }, - "/api/telemetry": { - "post": { - "tags": ["Telemetry"], - "summary": "Submit telemetry event", - "description": "Accept a single walkerOS v4 event from the CLI or MCP telemetry emitter. Public endpoint — no authentication. `source.type` is constrained to `cli` or `mcp`.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TelemetryEvent" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } } } - } - }, - "responses": { - "204": { - "description": "Telemetry event accepted" }, - "400": { - "description": "Validation error", + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -12467,11 +16623,11 @@ } } }, - "/api/projects/{projectId}/billing": { + "/api/projects/{projectId}/frames/{frameId}": { "get": { - "tags": ["Billing"], - "summary": "Get billing details", - "description": "Get billing details for a project, or null when none are set. Requires member role.", + "tags": ["Projects"], + "summary": "Read one frame", + "description": "One frame with its marks. A frame of another project reads back as nothing and answers 404, never 403, so this route cannot become an oracle for what exists elsewhere. A deleted frame is gone to every read. Requires member role.", "parameters": [ { "schema": { @@ -12482,22 +16638,36 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "description": "Frame ID (frm_...)" + }, + "required": true, + "description": "Frame ID (frm_...)", + "name": "frameId", + "in": "path" } ], "responses": { "200": { - "description": "Billing details (or null when unset)", + "description": "The frame", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/BillingDetailsResponse" - }, - { - "type": ["object", "null"] - } - ] + "$ref": "#/components/schemas/Frame" + } + } + } + }, + "400": { + "description": "The path segment does not address a frame", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12531,13 +16701,23 @@ } } } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, "put": { - "tags": ["Billing"], - "summary": "Upsert billing details", - "description": "Create or update billing details for a project. Requires owner role.", + "tags": ["Projects"], + "summary": "Create or replace one frame", + "description": "The path is the identity, so the body carries no id: a create is a write to an absent row at `baseVersion` 0 and everything else is a replace. `clientWriteId` is minted at compose time and is what makes a replayed drain exact: a write whose id already produced the stored version landed once and is answered with that version, writing nothing, so an offline queue drains repeatedly without turning one edit into two versions. A write against a version someone else has moved past answers 409 `FRAME_VERSION_CONFLICT` carrying the head, which is what lets a client raise keep-mine against load-theirs on the one frame that conflicted instead of dropping what a person drew. A name another live frame already holds is a distinct 409 `FRAME_NAME_EXISTS`. A relation naming a frame this project does not hold, or one that would place a frame inside itself, is 400 `INVALID_FRAME`. The screenshot is never touched here: a frame write carries no capture. Requires member role.", "parameters": [ { "schema": { @@ -12548,30 +16728,55 @@ "required": true, "name": "projectId", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "description": "Frame ID (frm_...)" + }, + "required": true, + "description": "Frame ID (frm_...)", + "name": "frameId", + "in": "path" } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertBillingDetailsRequest" + "type": "object", + "properties": { + "frame": { + "$ref": "#/components/schemas/FrameInput" + }, + "baseVersion": { + "type": "integer", + "minimum": 0 + }, + "clientWriteId": { + "type": "string", + "pattern": "^cw_[A-Za-z0-9_-]{21}$" + } + }, + "required": ["frame", "baseVersion", "clientWriteId"] } } } }, "responses": { "200": { - "description": "Billing details saved", + "description": "The stored version", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BillingDetailsResponse" + "$ref": "#/components/schemas/PutFrameResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body, a bad relation, or a path segment that addresses no frame", "content": { "application/json": { "schema": { @@ -12609,15 +16814,40 @@ } } } + }, + "409": { + "description": "A stale base version, carrying the head, or a name another live frame already holds. Only the version conflict carries `head`: a name clash needs no frame to resolve, since the client already knows the name it sent.", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FrameConflictResponse" + }, + { + "$ref": "#/components/schemas/ErrorResponse" + } + ] + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}": { - "get": { - "tags": ["Deployments"], - "summary": "Get deployment detail", - "description": "Get deployment detail. Accepts a dep_ID or a slug. Requires member role.", + }, + "delete": { + "tags": ["Projects"], + "summary": "Delete one frame", + "description": "Soft-delete the frame and, transitively, every variation of what this delete removes. Children are not variations and survive: each live frame under a removed one is re-parented to its nearest live ancestor in the same transaction, and one left with no live ancestor becomes top-level and inherits the page it hung under, so nothing is left unreachable. Those re-parents are server writes that bump their own versions, so a client still holding a pre-delete version meets a conflict carrying the new parent. A frame this project does not hold answers 404: a delete that removed nothing is not a delete that succeeded. Requires member role.", "parameters": [ { "schema": { @@ -12632,20 +16862,25 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "description": "Frame ID (frm_...)" }, "required": true, - "name": "deploymentId", + "description": "Frame ID (frm_...)", + "name": "frameId", "in": "path" } ], "responses": { - "200": { - "description": "Deployment detail", + "204": { + "description": "The frame is deleted" + }, + "400": { + "description": "The path segment does not address a frame", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentDetailResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12691,11 +16926,13 @@ } } } - }, - "patch": { - "tags": ["Deployments"], - "summary": "Update deployment", - "description": "Update a deployment's label, or stop/resume it. Stop and resume require admin role; label updates require member role.", + } + }, + "/api/projects/{projectId}/frames/{frameId}/screenshot": { + "post": { + "tags": ["Projects"], + "summary": "Store the capture of one frame", + "description": "Store one screenshot and set it on its frame. The image arrives as base64 rather than multipart, because the extension relay carries string bodies only. The server decides everything about the bytes: it decodes them, counts the DECODED length against a 4 MB cap, reads the type from the file’s own magic bytes, and hashes them, so nothing the client claims about size or type is consulted. Captures are deduplicated by content within a project: identical pixels resolve to one asset and one upload, and `reused` says whether that happened, which is the common answer rather than the rare one because re-capturing an unchanged frame produces identical bytes. A body past the cap is 413 `PAYLOAD_TOO_LARGE` and one that is not a PNG is 415 `UNSUPPORTED_MEDIA_TYPE`. The capture bumps no frame version: it is not an edit, so an upload never conflicts with the frame write the client queued beside it. Requires member role.", "parameters": [ { "schema": { @@ -12710,10 +16947,12 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^frm_[A-Za-z0-9_-]{21}$", + "description": "Frame ID (frm_...)" }, "required": true, - "name": "deploymentId", + "description": "Frame ID (frm_...)", + "name": "frameId", "in": "path" } ], @@ -12723,32 +16962,32 @@ "schema": { "type": "object", "properties": { - "label": { + "imageBase64": { "type": "string", - "maxLength": 255 + "minLength": 1 }, - "action": { - "type": "string", - "enum": ["stop", "resume"] + "meta": { + "$ref": "#/components/schemas/FrameScreenshotMeta" } - } + }, + "required": ["imageBase64", "meta"] } } } }, "responses": { "200": { - "description": "Deployment updated", + "description": "The asset the bytes resolved to, and whether it already existed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateDeploymentResponse" + "$ref": "#/components/schemas/ScreenshotUploadResponse" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body, or a path segment that addresses no frame", "content": { "application/json": { "schema": { @@ -12778,7 +17017,7 @@ } }, "404": { - "description": "Not found", + "description": "This project does not hold the named frame", "content": { "application/json": { "schema": { @@ -12787,8 +17026,28 @@ } } }, - "409": { - "description": "Deployment state changed concurrently", + "413": { + "description": "The decoded image is past the 4 MB cap", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "415": { + "description": "The bytes are not a PNG", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -12798,11 +17057,13 @@ } } } - }, - "delete": { - "tags": ["Deployments"], - "summary": "Delete deployment", - "description": "Tear down and soft-delete a deployment. Idempotent. Requires owner role.", + } + }, + "/api/projects/{projectId}/assets/{assetId}": { + "get": { + "tags": ["Projects"], + "summary": "Read one stored capture", + "description": "The bytes of one frame screenshot, for the app canvas. The extension keeps its own capture locally and never reads assets back. Same-origin and session-authenticated: the response carries `Cross-Origin-Resource-Policy: same-origin`, so no other site can embed a tenant capture off the reader’s session. The bytes are immutable by construction, since the object key is their own content hash, which is why they are cacheable for a year, and `private` keeps a shared cache from serving one tenant’s capture to the next request for the same URL. An asset another project holds answers 404, never 403. Requires member role.", "parameters": [ { "schema": { @@ -12817,16 +17078,107 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^fas_[A-Za-z0-9_-]{21}$", + "description": "Asset ID (fas_...)" }, "required": true, - "name": "deploymentId", + "description": "Asset ID (fas_...)", + "name": "assetId", "in": "path" } ], "responses": { - "204": { - "description": "Deployment deleted (or already absent)" + "200": { + "description": "The image bytes", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "The path segment does not address an asset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/canvases": { + "get": { + "tags": ["Projects"], + "summary": "List the canvases of the project", + "description": "A canvas is a named, freely arranged board over a project’s frames, the surface on which a plan is laid out across pages rather than within one. This returns every live canvas by name WITHOUT its document: the document is the bulk of a canvas and a listing renders none of it, so opening a board is the single-canvas read. Requires member role.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The project’s canvases, without their documents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CanvasListResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -12848,6 +17200,16 @@ } } }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "429": { "description": "Rate limited", "content": { @@ -12859,13 +17221,11 @@ } } } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}/publish": { + }, "post": { - "tags": ["Deployments"], - "summary": "Publish deployment version", - "description": "Push a new version to a deployment, either from an existing flow setting or from a direct config upload. Bundles in-process and transitions the deployment to `deploying`. Requires member role.", + "tags": ["Projects"], + "summary": "Create one canvas", + "description": "Create one empty canvas at version 1. The id is the client’s, so a board drawn before the first save keeps its identity when it arrives. A canvas comes into existence here and nowhere else: a document write to an id the project does not hold is a 404 rather than a create, which is what keeps a stray write from minting a board. A name another live canvas already holds is 409 `CANVAS_NAME_EXISTS`; the partial unique index is over live rows, so a name a deleted canvas still carries is free. An id that is not a canvas id is refused by the body schema as 400 `VALIDATION_ERROR`. An id that is not available, because a canvas, in this project or another, already holds it, is 400 `INVALID_CANVAS`, whose message says nothing about the project that holds it. Requires member role.", "parameters": [ { "schema": { @@ -12876,83 +17236,43 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "example": "dep_abc123" - }, - "required": true, - "name": "deploymentId", - "in": "path" - }, - { - "schema": { - "type": "string", - "description": "Optional client key to make publishing idempotent." - }, - "required": false, - "description": "Optional client key to make publishing idempotent.", - "name": "idempotency-key", - "in": "header" } ], "requestBody": { "content": { "application/json": { "schema": { - "oneOf": [ - { - "type": "object", - "properties": { - "source": { - "type": "string", - "enum": ["flow"] - }, - "flowId": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$" - }, - "flowSettingsName": { - "type": "string", - "minLength": 1, - "maxLength": 100 - } - }, - "required": ["source", "flowId", "flowSettingsName"] + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^cnv_[A-Za-z0-9_-]{21}$", + "example": "cnv_V1StGXR8Z5jdHi6BmyT7K" }, - { - "type": "object", - "properties": { - "source": { - "type": "string", - "enum": ["config"] - }, - "config": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["source", "config"] + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 } - ] + }, + "required": ["id", "name"] } } } }, "responses": { "201": { - "description": "Version published (bundling/deploying)", + "description": "The created canvas, with its empty document", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublishVersionResponse" + "$ref": "#/components/schemas/Canvas" } } } }, "400": { - "description": "Validation error", + "description": "Invalid body, or an id that is not available", "content": { "application/json": { "schema": { @@ -12992,7 +17312,7 @@ } }, "409": { - "description": "Publish already in progress", + "description": "A name another live canvas already holds", "content": { "application/json": { "schema": { @@ -13002,17 +17322,7 @@ } }, "429": { - "description": "Rate limited or concurrent deploy limit", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "Build service unavailable", + "description": "Rate limited", "content": { "application/json": { "schema": { @@ -13024,11 +17334,11 @@ } } }, - "/api/projects/{projectId}/deployments/{deploymentId}/stream": { + "/api/projects/{projectId}/canvases/{canvasId}": { "get": { - "tags": ["Deployments"], - "summary": "Stream deployment status (SSE)", - "description": "Server-Sent Events (`text/event-stream`) stream of a deployment's live status. Emits named events: `status` (a snapshot payload, schema below), `done` (terminal, no body), and `timeout`. The CLI consumes this with a raw fetch while waiting for a deploy to finish. Requires member role. The schema documents the JSON `data:` of a `status` event; `errorCode`/`errorMessage` carry the persisted, redacted classification of a failed deploy.", + "tags": ["Projects"], + "summary": "Read one canvas", + "description": "One canvas with its whole document: the nodes with their positions, the edges, and the node keys the board suppresses. A canvas of another project reads back as nothing and answers 404, never 403, so this route cannot become an oracle for what exists elsewhere. A deleted canvas is gone to every read. Requires member role.", "parameters": [ { "schema": { @@ -13043,36 +17353,29 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^cnv_[A-Za-z0-9_-]{21}$", + "example": "cnv_V1StGXR8Z5jdHi6BmyT7K", + "description": "Canvas ID (cnv_...)" }, "required": true, - "name": "deploymentId", + "description": "Canvas ID (cnv_...)", + "name": "canvasId", "in": "path" } ], "responses": { "200": { - "description": "SSE stream; `status` event payload shape documented here.", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/DeploymentStreamStatusEvent" - } - } - } - }, - "401": { - "description": "Unauthorized", + "description": "The canvas", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/Canvas" } } } }, - "404": { - "description": "Not found", + "400": { + "description": "The path segment does not address a canvas", "content": { "application/json": { "schema": { @@ -13080,65 +17383,6 @@ } } } - } - } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}/versions": { - "get": { - "tags": ["Deployments"], - "summary": "List deployment versions", - "description": "List the version history for a deployment, paginated. Requires member role.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "example": "dep_abc123" - }, - "required": true, - "name": "deploymentId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Version history", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDeploymentVersionsResponse" - } - } - } }, "401": { "description": "Unauthorized", @@ -13181,13 +17425,11 @@ } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases": { - "get": { - "tags": ["Deployments"], - "summary": "List flow releases", - "description": "List the release history for a flow across all of its deployment lineages, newest first, paginated. Each entry is a deployed version joined to its parent deployment (slug and type). Requires member role.", + }, + "put": { + "tags": ["Projects"], + "summary": "Replace the document of one canvas", + "description": "The whole board every time: a canvas is read and written as a unit, so there is no partial write to reconcile. `clientWriteId` is minted at compose time and is what makes a replayed drain exact: a write whose id already produced the stored version landed once and is answered with that version, writing nothing, so an offline queue drains repeatedly without turning one edit into two versions. A write against a version someone else has moved past answers 409 `CANVAS_VERSION_CONFLICT` carrying the head, which is what lets a client raise keep-mine against load-theirs on the board that conflicted instead of dropping what a person drew. A canvas this project does not hold, or one that was removed, is 404 `CANVAS_NOT_FOUND`: this door replaces a document and never creates one. Requires member role.", "parameters": [ { "schema": { @@ -13202,40 +17444,56 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "pattern": "^cnv_[A-Za-z0-9_-]{21}$", + "example": "cnv_V1StGXR8Z5jdHi6BmyT7K", + "description": "Canvas ID (cnv_...)" }, "required": true, - "name": "flowId", + "description": "Canvas ID (cnv_...)", + "name": "canvasId", "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "document": { + "$ref": "#/components/schemas/CanvasDocument" + }, + "baseVersion": { + "type": "integer", + "minimum": 0 + }, + "clientWriteId": { + "type": "string", + "pattern": "^cw_[A-Za-z0-9_-]{21}$" + } + }, + "required": ["document", "baseVersion", "clientWriteId"] + } + } + } + }, "responses": { "200": { - "description": "Flow release history", + "description": "The stored version", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListFlowReleasesResponse" + "$ref": "#/components/schemas/PutCanvasResponse" + } + } + } + }, + "400": { + "description": "Invalid body, or a path segment that addresses no canvas", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13261,7 +17519,7 @@ } }, "404": { - "description": "Not found", + "description": "This project does not hold the named canvas", "content": { "application/json": { "schema": { @@ -13270,6 +17528,16 @@ } } }, + "409": { + "description": "A stale base version, carrying the head", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CanvasConflictResponse" + } + } + } + }, "429": { "description": "Rate limited", "content": { @@ -13283,11 +17551,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/releases/annotations": { + "/api/projects/{projectId}/flows/{flowId}/releases/step-history": { "get": { "tags": ["Deployments"], - "summary": "List release rationale", - "description": "Read the rationale attached to the given releases of a flow. `versionIds` is a comma-separated list of spine version ids (at most 100), all of which must belong to this flow. Releases without rationale are absent from the response. Requires member role.", + "summary": "List the releases that touched one step", + "description": "The releases of this flow that added, changed, or removed one step, newest first, each carrying the rationale stored for it. `step` is a `type.name` key over source, transformer, destination, store, and contract. `flow` narrows the scan to one named flow inside the config and is ignored for a contract key. `limit` bounds the releases scanned, not the entries returned. When nothing matched, `knownSteps` lists the addressable keys of the newest scanned release. Requires member role.", "parameters": [ { "schema": { @@ -13312,26 +17580,44 @@ { "schema": { "type": "string", - "example": "ver_a1b2c3d4,ver_e5f6g7h8" + "example": "destination.ga4" + }, + "required": true, + "name": "step", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "web" }, - "required": true, - "name": "versionIds", + "required": false, + "name": "flow", + "in": "query" + }, + { + "schema": { + "type": ["integer", "null"], + "example": 25 + }, + "required": false, + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "Rationale for the requested releases", + "description": "The releases that touched the step", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListVersionAnnotationsResponse" + "$ref": "#/components/schemas/StepHistoryResponse" } } } }, "400": { - "description": "Invalid version ids", + "description": "Invalid step key or query", "content": { "application/json": { "schema": { @@ -13381,11 +17667,13 @@ } } } - }, - "put": { + } + }, + "/api/projects/{projectId}/flows/{flowId}/releases/summarize": { + "post": { "tags": ["Deployments"], - "summary": "Write release rationale", - "description": "Create or update the human rationale for one release of this flow. A null `humanText` clears it. The generated summary is machine-written and cannot be set through this route. The target must be a numbered release version of this flow, not an autosave revision. Requires member role.", + "summary": "Summarize or check a release", + "description": "Describe what one release of this flow changed against an earlier release, or check a written note against that same change. In `draft` mode the generated text is stored as the release's generated summary; in `check` mode nothing is stored and the response says whether the note matches. The diff is always recomputed from the two stored snapshots, with secret literals masked, and is never taken from the request. Both versions must be numbered releases of this flow, and `prevVersionId` must be the earlier of the two. Requires member role and the `hub` feature.", "parameters": [ { "schema": { @@ -13419,29 +17707,39 @@ "pattern": "^ver_[a-zA-Z0-9_-]+$", "example": "ver_a1b2c3d4" }, - "humanText": { - "type": ["string", "null"], + "prevVersionId": { + "type": "string", + "pattern": "^ver_[a-zA-Z0-9_-]+$", + "example": "ver_a1b2c3d4" + }, + "mode": { + "type": "string", + "enum": ["draft", "check"] + }, + "currentText": { + "type": "string", + "minLength": 1, "maxLength": 4000 } }, - "required": ["versionId", "humanText"] + "required": ["versionId", "prevVersionId", "mode"] } } } }, "responses": { "200": { - "description": "The stored rationale", + "description": "The generated summary or the check verdict", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertVersionAnnotationResponse" + "$ref": "#/components/schemas/SummarizeReleaseResponse" } } } }, "400": { - "description": "Invalid body, or the target is not a release", + "description": "Invalid body, a target is not a release, the pair is out of order, or no LLM provider is configured", "content": { "application/json": { "schema": { @@ -13489,15 +17787,25 @@ } } } + }, + "502": { + "description": "The model call failed or returned no usable text", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/api/projects/{projectId}/flows/{flowId}/threads": { + "/api/projects/{projectId}/deployments/{deploymentId}/versions/current/content": { "get": { "tags": ["Deployments"], - "summary": "List discussion threads on a flow", - "description": "Threads anchored to things in this flow, most recently active first. `anchorType` and `anchorKey` narrow to one anchor and are only meaningful together. `includeMessages=true` attaches the messages; otherwise each thread carries `messageCount` alone. Attaching them holds the page to a smaller ceiling than the lean index and caps each thread at its newest 50 messages, with `hasMoreMessages` set when a thread holds more. Because that ceiling is below the `limit` a caller may pass, the response carries `hasMoreThreads`: a full page is not proof of a complete list. A resolved thread carries the release that settled it, and `resolvedByVersionId` is null once that release is gone, which is what `anchorLabel` is kept for. Requires member role.", + "summary": "Get current deployed content", + "description": "Get the active deployed per-setting content for a deployment, used to diff changes since deploy. Content is masked and display-only; every field is null when there is no deployed baseline. Requires member role.", "parameters": [ { "schema": { @@ -13512,78 +17820,20 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "dep_abc123" }, "required": true, - "name": "flowId", + "name": "deploymentId", "in": "path" - }, - { - "schema": { - "type": "string", - "enum": ["step", "entity_action", "release", "contract", "tag"], - "example": "release" - }, - "required": false, - "name": "anchorType", - "in": "query" - }, - { - "schema": { - "type": "string", - "example": "ver_a1b2c3d4" - }, - "required": false, - "name": "anchorKey", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["open", "resolved"], - "example": "open" - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["true", "false"] - }, - "required": false, - "name": "includeMessages", - "in": "query" - }, - { - "schema": { - "type": "integer", - "example": 50 - }, - "required": false, - "name": "limit", - "in": "query" } ], "responses": { "200": { - "description": "Threads on this flow", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListHubThreadsResponse" - } - } - } - }, - "400": { - "description": "Invalid query", + "description": "Deployed content (or a null baseline)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/DeployedContentResponse" } } } @@ -13629,11 +17879,13 @@ } } } - }, - "post": { + } + }, + "/api/projects/{projectId}/deployments/{deploymentId}/heartbeats": { + "get": { "tags": ["Deployments"], - "summary": "Open a discussion thread", - "description": "Open a thread on one anchor, with its first message. A thread never exists empty, so `text` is required and may not be blank. A `release` anchor must name a numbered release of this flow: the server verifies it and derives the label, so `anchorLabel` is ignored for that type. For any other anchor type `anchorLabel` is a display snapshot of the anchor as it reads now, stored so a later rename leaves the thread readable instead of unlabeled, and defaults to the anchor key. Requires member role.", + "summary": "List deployment heartbeats", + "description": "List heartbeat records for a deployment with optional from/to time-range filtering and pagination. Requires member role.", "parameters": [ { "schema": { @@ -13648,69 +17900,59 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "dep_abc123" }, "required": true, - "name": "flowId", + "name": "deploymentId", "in": "path" + }, + { + "schema": { + "type": "string", + "description": "ISO start of the time range." + }, + "required": false, + "description": "ISO start of the time range.", + "name": "from", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "ISO end of the time range." + }, + "required": false, + "description": "ISO end of the time range.", + "name": "to", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": ["integer", "null"], + "minimum": 0 + }, + "required": false, + "name": "offset", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "anchorType": { - "type": "string", - "enum": [ - "step", - "entity_action", - "release", - "contract", - "tag" - ], - "example": "release" - }, - "anchorKey": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "anchorLabel": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "text": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - } - }, - "required": ["anchorType", "anchorKey", "text"] - } - } - } - }, "responses": { - "201": { - "description": "The opened thread, with its first message", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HubThreadResponse" - } - } - } - }, - "400": { - "description": "Invalid body, or the anchor is not a release", + "200": { + "description": "Heartbeat history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListHeartbeatsResponse" } } } @@ -13725,18 +17967,8 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Flow not found, or the anchor names no release of it", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -13745,8 +17977,8 @@ } } }, - "429": { - "description": "Rate limited", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -13758,11 +17990,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/threads/{threadId}/messages": { + "/api/projects/{projectId}/deployments/{deploymentId}/rotate-ingest-token": { "post": { "tags": ["Deployments"], - "summary": "Reply to a thread", - "description": "Append a message to a thread. `text` may not be blank: a message cannot be cleared, so empty is invalid rather than a way to erase one. Replying does not reopen a resolved thread: the resolve link is a statement about a release and is never retracted implicitly. Requires member role.", + "summary": "Rotate ingest token", + "description": "Rotate the ingest token for a deployment. Owner-only. No grace window: the previous token is immediately invalidated and the new token is returned once.", "parameters": [ { "schema": { @@ -13777,60 +18009,20 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^thr_[a-z0-9]+$", - "example": "thr_a1b2c3d4", - "description": "Thread ID (thr_...)" + "example": "dep_abc123" }, "required": true, - "description": "Thread ID (thr_...)", - "name": "threadId", + "name": "deploymentId", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - } - }, - "required": ["text"] - } - } - } - }, "responses": { - "201": { - "description": "The thread, with the new message", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HubThreadResponse" - } - } - } - }, - "400": { - "description": "Invalid body", + "200": { + "description": "New ingest token", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/RotateIngestTokenResponse" } } } @@ -13864,25 +18056,15 @@ } } } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/threads/{threadId}": { - "patch": { + "/api/projects/{projectId}/deployments/{deploymentId}/usage": { + "get": { "tags": ["Deployments"], - "summary": "Resolve or reopen a thread", - "description": "Resolving records the release that settled the thread: pass `resolvedByVersionId`, or omit it to record the flow’s newest release. The target must be a numbered release of this flow, never an autosave revision. Reopening drops the link. Requires member role.", + "summary": "Deployment usage", + "description": "Aggregate usage summary plus bucketed chart data for a deployment over the requested period. Requires member role.", "parameters": [ { "schema": { @@ -13897,61 +18079,37 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "example": "dep_abc123" }, "required": true, - "name": "flowId", + "name": "deploymentId", "in": "path" }, { "schema": { "type": "string", - "pattern": "^thr_[a-z0-9]+$", - "example": "thr_a1b2c3d4", - "description": "Thread ID (thr_...)" + "enum": ["1h", "24h", "7d", "30d"], + "description": "Time window for the usage summary." }, - "required": true, - "description": "Thread ID (thr_...)", - "name": "threadId", - "in": "path" + "required": false, + "description": "Time window for the usage summary.", + "name": "period", + "in": "query" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["open", "resolved"], - "example": "open" - }, - "resolvedByVersionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$", - "example": "ver_a1b2c3d4" - } - }, - "required": ["status"] - } - } - } - }, "responses": { "200": { - "description": "The updated thread", + "description": "Usage summary and chart buckets", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HubThreadResponse" + "$ref": "#/components/schemas/DeploymentUsageResponse" } } } }, "400": { - "description": "Invalid body, or the target is not a release", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -13989,25 +18147,15 @@ } } } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/releases/step-history": { + "/api/projects/{projectId}/flows/{flowId}/custom-domains": { "get": { "tags": ["Deployments"], - "summary": "List the releases that touched one step", - "description": "The releases of this flow that added, changed, or removed one step, newest first, each carrying the rationale stored for it. `step` is a `type.name` key over source, transformer, destination, store, and contract. `flow` narrows the scan to one named flow inside the config and is ignored for a contract key. `limit` bounds the releases scanned, not the entries returned. When nothing matched, `knownSteps` lists the addressable keys of the newest scanned release. Requires member role.", + "summary": "List custom domains", + "description": "List custom domains attached to any deployment of this flow. Requires member role and the customDomains feature.", "parameters": [ { "schema": { @@ -14028,52 +18176,15 @@ "required": true, "name": "flowId", "in": "path" - }, - { - "schema": { - "type": "string", - "example": "destination.ga4" - }, - "required": true, - "name": "step", - "in": "query" - }, - { - "schema": { - "type": "string", - "example": "web" - }, - "required": false, - "name": "flow", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "example": 25 - }, - "required": false, - "name": "limit", - "in": "query" } ], "responses": { "200": { - "description": "The releases that touched the step", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StepHistoryResponse" - } - } - } - }, - "400": { - "description": "Invalid step key or query", + "description": "Custom domains for the flow", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ListCustomDomainsResponse" } } } @@ -14097,101 +18208,57 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } - } - }, - "/api/projects/{projectId}/flows/{flowId}/releases/summarize": { + }, "post": { "tags": ["Deployments"], - "summary": "Summarize or check a release", - "description": "Describe what one release of this flow changed against an earlier release, or check a written note against that same change. In `draft` mode the generated text is stored as the release's generated summary; in `check` mode nothing is stored and the response says whether the note matches. The diff is always recomputed from the two stored snapshots, with secret literals masked, and is never taken from the request. Both versions must be numbered releases of this flow, and `prevVersionId` must be the earlier of the two. Requires member role and the `hub` feature.", + "summary": "Attach custom domain", + "description": "Attach a custom domain to the flow's latest server deployment, or to an explicit deployment supplied in the body. Requires member role and the customDomains feature.", "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "versionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$", - "example": "ver_a1b2c3d4" - }, - "prevVersionId": { - "type": "string", - "pattern": "^ver_[a-zA-Z0-9_-]+$", - "example": "ver_a1b2c3d4" - }, - "mode": { - "type": "string", - "enum": ["draft", "check"] - }, - "currentText": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - } - }, - "required": ["versionId", "prevVersionId", "mode"] + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomDomainRequest" } } } }, "responses": { - "200": { - "description": "The generated summary or the check verdict", + "201": { + "description": "Custom domain attached", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SummarizeReleaseResponse" + "$ref": "#/components/schemas/CustomDomain" } } } }, "400": { - "description": "Invalid body, a target is not a release, the pair is out of order, or no LLM provider is configured", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -14230,18 +18297,8 @@ } } }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "The model call failed or returned no usable text", + "409": { + "description": "Conflict", "content": { "application/json": { "schema": { @@ -14253,11 +18310,11 @@ } } }, - "/api/projects/{projectId}/deployments/{deploymentId}/versions/current/content": { - "get": { + "/api/projects/{projectId}/flows/{flowId}/custom-domains/{domainId}": { + "delete": { "tags": ["Deployments"], - "summary": "Get current deployed content", - "description": "Get the active deployed per-setting content for a deployment, used to diff changes since deploy. Content is masked and display-only; every field is null when there is no deployed baseline. Requires member role.", + "summary": "Detach custom domain", + "description": "Detach a custom domain from its deployment and remove the Scaleway record. Idempotent: a missing domain still returns 204.", "parameters": [ { "schema": { @@ -14272,23 +18329,26 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "deploymentId", + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "example": "dom_abc123" + }, + "required": true, + "name": "domainId", "in": "path" } ], "responses": { - "200": { - "description": "Deployed content (or a null baseline)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeployedContentResponse" - } - } - } + "204": { + "description": "Custom domain detached" }, "401": { "description": "Unauthorized", @@ -14309,35 +18369,15 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/deployments/{deploymentId}/heartbeats": { + "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy-token": { "get": { - "tags": ["Deployments"], - "summary": "List deployment heartbeats", - "description": "List heartbeat records for a deployment with optional from/to time-range filtering and pagination. Requires member role.", + "tags": ["Settings"], + "summary": "Self-hosted deploy-token status", + "description": "Report whether a self-hosted deploy token exists for this config, plus the deployment health summary when present. Requires member role.", "parameters": [ { "schema": { @@ -14352,59 +18392,31 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "deploymentId", + "name": "flowId", "in": "path" }, { "schema": { "type": "string", - "description": "ISO start of the time range." - }, - "required": false, - "description": "ISO start of the time range.", - "name": "from", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "ISO end of the time range." - }, - "required": false, - "description": "ISO end of the time range.", - "name": "to", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" }, - "required": false, - "name": "offset", - "in": "query" + "required": true, + "name": "settingsId", + "in": "path" } ], "responses": { "200": { - "description": "Heartbeat history", + "description": "Deploy-token status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListHeartbeatsResponse" + "$ref": "#/components/schemas/DeployTokenStatusResponse" } } } @@ -14440,13 +18452,11 @@ } } } - } - }, - "/api/projects/{projectId}/deployments/{deploymentId}/rotate-ingest-token": { + }, "post": { - "tags": ["Deployments"], - "summary": "Rotate ingest token", - "description": "Rotate the ingest token for a deployment. Owner-only. No grace window: the previous token is immediately invalidated and the new token is returned once.", + "tags": ["Settings"], + "summary": "Mint self-hosted deploy token", + "description": "Create a self-hosted deployment (if none exists) and mint a flow+deployment-bound runner token. Admin-only. The raw token is returned once and never stored in plaintext.", "parameters": [ { "schema": { @@ -14461,20 +18471,31 @@ { "schema": { "type": "string", - "example": "dep_abc123" + "pattern": "^flow_[a-zA-Z0-9_-]+$", + "example": "flow_a1b2c3d4" }, "required": true, - "name": "deploymentId", + "name": "flowId", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^cfg_[a-zA-Z0-9_-]+$", + "example": "cfg_a1b2c3d4" + }, + "required": true, + "name": "settingsId", "in": "path" } ], "responses": { - "200": { - "description": "New ingest token", + "201": { + "description": "Deploy token minted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RotateIngestTokenResponse" + "$ref": "#/components/schemas/CreateDeployTokenResponse" } } } @@ -14512,11 +18533,11 @@ } } }, - "/api/projects/{projectId}/deployments/{deploymentId}/usage": { + "/api/projects/{projectId}/entitlements": { "get": { - "tags": ["Deployments"], - "summary": "Deployment usage", - "description": "Aggregate usage summary plus bucketed chart data for a deployment over the requested period. Requires member role.", + "tags": ["Projects"], + "summary": "Resolved entitlements", + "description": "Return resolved feature entitlements for the authenticated user and project. Used by CLI/API clients; the web UI uses SSR-resolved entitlements. Requires viewer role.", "parameters": [ { "schema": { @@ -14527,45 +18548,15 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "example": "dep_abc123" - }, - "required": true, - "name": "deploymentId", - "in": "path" - }, - { - "schema": { - "type": "string", - "enum": ["1h", "24h", "7d", "30d"], - "description": "Time window for the usage summary." - }, - "required": false, - "description": "Time window for the usage summary.", - "name": "period", - "in": "query" } ], "responses": { "200": { - "description": "Usage summary and chart buckets", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploymentUsageResponse" - } - } - } - }, - "400": { - "description": "Validation error", + "description": "Resolved entitlements", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/EntitlementsResponse" } } } @@ -14589,25 +18580,15 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/custom-domains": { + "/api/projects/{projectId}/settings/llm": { "get": { - "tags": ["Deployments"], - "summary": "List custom domains", - "description": "List custom domains attached to any deployment of this flow. Requires member role and the customDomains feature.", + "tags": ["Settings"], + "summary": "Active LLM provider", + "description": "Report which LLM provider is currently active for the project and where billing is sourced. Never returns the apiKey. Requires member role and the chat feature.", "parameters": [ { "schema": { @@ -14616,27 +18597,17 @@ "example": "proj_x7y8z9" }, "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", + "name": "projectId", "in": "path" } ], "responses": { "200": { - "description": "Custom domains for the flow", + "description": "Active LLM provider status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListCustomDomainsResponse" + "$ref": "#/components/schemas/LlmConfigStatusResponse" } } } @@ -14660,13 +18631,23 @@ } } } + }, + "503": { + "description": "No platform LLM provider configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LlmConfigStatusResponse" + } + } + } } } }, "post": { - "tags": ["Deployments"], - "summary": "Attach custom domain", - "description": "Attach a custom domain to the flow's latest server deployment, or to an explicit deployment supplied in the body. Requires member role and the customDomains feature.", + "tags": ["Settings"], + "summary": "Set LLM provider", + "description": "Set or clear the project LLM provider override. Admin-only, gated by the chat feature. The apiKey is write-only: it is encrypted and never returned.", "parameters": [ { "schema": { @@ -14677,34 +18658,24 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCustomDomainRequest" + "$ref": "#/components/schemas/SetLlmConfigRequest" } } } }, "responses": { - "201": { - "description": "Custom domain attached", + "200": { + "description": "LLM config saved or cleared", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CustomDomain" + "$ref": "#/components/schemas/SetLlmConfigResponse" } } } @@ -14748,25 +18719,15 @@ } } } - }, - "409": { - "description": "Conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/api/projects/{projectId}/flows/{flowId}/custom-domains/{domainId}": { - "delete": { - "tags": ["Deployments"], - "summary": "Detach custom domain", - "description": "Detach a custom domain from its deployment and remove the Scaleway record. Idempotent: a missing domain still returns 204.", + "/api/projects/{projectId}/chat/sessions": { + "get": { + "tags": ["Chat"], + "summary": "List chat sessions", + "description": "List the caller's recent chat sessions for a project, ordered by last activity. Requires member role and the chat feature.", "parameters": [ { "schema": { @@ -14780,27 +18741,34 @@ }, { "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" + "type": "integer", + "minimum": 1, + "maximum": 100 }, - "required": true, - "name": "flowId", - "in": "path" + "required": false, + "name": "limit", + "in": "query" }, { "schema": { - "type": "string", - "example": "dom_abc123" + "type": ["integer", "null"], + "minimum": 0 }, - "required": true, - "name": "domainId", - "in": "path" + "required": false, + "name": "offset", + "in": "query" } ], "responses": { - "204": { - "description": "Custom domain detached" + "200": { + "description": "Chat session list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListChatSessionsResponse" + } + } + } }, "401": { "description": "Unauthorized", @@ -14825,11 +18793,11 @@ } } }, - "/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy-token": { + "/api/projects/{projectId}/chat/sessions/{sessionId}": { "get": { - "tags": ["Settings"], - "summary": "Self-hosted deploy-token status", - "description": "Report whether a self-hosted deploy token exists for this config, plus the deployment health summary when present. Requires member role.", + "tags": ["Chat"], + "summary": "Get chat session", + "description": "Return a chat session and its full message history when the caller owns it. Foreign or unknown sessions return 404 (never 403) so existence is not leaked. Requires member role and the chat feature.", "parameters": [ { "schema": { @@ -14844,31 +18812,20 @@ { "schema": { "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" + "example": "sess_abc123" }, "required": true, - "name": "settingsId", + "name": "sessionId", "in": "path" } ], "responses": { "200": { - "description": "Deploy-token status", + "description": "Chat session with messages", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployTokenStatusResponse" + "$ref": "#/components/schemas/ChatSessionDetailResponse" } } } @@ -14904,11 +18861,13 @@ } } } - }, + } + }, + "/api/projects/{projectId}/chat/elicit": { "post": { - "tags": ["Settings"], - "summary": "Mint self-hosted deploy token", - "description": "Create a self-hosted deployment (if none exists) and mint a flow+deployment-bound runner token. Admin-only. The raw token is returned once and never stored in plaintext.", + "tags": ["Chat"], + "summary": "Answer elicitation prompt", + "description": "Answer a pending MCP elicitation prompt (accept, decline, or cancel), unblocking the waiting tool invocation. Requires member role and the chat feature.", "parameters": [ { "schema": { @@ -14919,35 +18878,34 @@ "required": true, "name": "projectId", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^flow_[a-zA-Z0-9_-]+$", - "example": "flow_a1b2c3d4" - }, - "required": true, - "name": "flowId", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^cfg_[a-zA-Z0-9_-]+$", - "example": "cfg_a1b2c3d4" - }, - "required": true, - "name": "settingsId", - "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElicitRequest" + } + } + } + }, "responses": { - "201": { - "description": "Deploy token minted", + "200": { + "description": "Elicitation resolved", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateDeployTokenResponse" + "$ref": "#/components/schemas/ElicitResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -14985,11 +18943,11 @@ } } }, - "/api/projects/{projectId}/entitlements": { + "/api/projects/{projectId}/runners": { "get": { - "tags": ["Projects"], - "summary": "Resolved entitlements", - "description": "Return resolved feature entitlements for the authenticated user and project. Used by CLI/API clients; the web UI uses SSR-resolved entitlements. Requires viewer role.", + "tags": ["Deployments"], + "summary": "List runners (deprecated)", + "description": "Deprecated: runners migrated to deployments (origin=self-hosted). Always returns an empty list for backward compatibility. Requires member role.", "parameters": [ { "schema": { @@ -15004,11 +18962,81 @@ ], "responses": { "200": { - "description": "Resolved entitlements", + "description": "Empty runner list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EntitlementsResponse" + "$ref": "#/components/schemas/ListRunnersResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/projects/{projectId}/runners/heartbeat": { + "post": { + "tags": ["Deployments"], + "summary": "Runner heartbeat", + "description": "Accept a self-hosted runner heartbeat with usage counters. Authenticated by a flow+deployment-bound runner token. Updates deployment liveness and records an immutable usage row.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^proj_[a-zA-Z0-9_-]+$", + "example": "proj_x7y8z9" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeartbeatRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Heartbeat accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunnerHeartbeatResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15023,8 +19051,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -15036,36 +19064,46 @@ } } }, - "/api/projects/{projectId}/settings/llm": { + "/api/packages": { "get": { - "tags": ["Settings"], - "summary": "Active LLM provider", - "description": "Report which LLM provider is currently active for the project and where billing is sourced. Never returns the apiKey. Requires member role and the chat feature.", + "tags": ["System"], + "summary": "Package catalog", + "description": "Resolved `@walkeros/*` package catalog for the add-step picker, optionally filtered by type and platform.", "parameters": [ { "schema": { "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" + "description": "Filter by package type." }, - "required": true, - "name": "projectId", - "in": "path" + "required": false, + "description": "Filter by package type.", + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by platform." + }, + "required": false, + "description": "Filter by platform.", + "name": "platform", + "in": "query" } ], "responses": { "200": { - "description": "Active LLM provider status", + "description": "Package catalog", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LlmConfigStatusResponse" + "$ref": "#/components/schemas/PackageCatalogResponse" } } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -15074,8 +19112,8 @@ } } }, - "403": { - "description": "Forbidden", + "502": { + "description": "Package catalog unavailable", "content": { "application/json": { "schema": { @@ -15083,13 +19121,32 @@ } } } + } + } + } + }, + "/api/packages/search": { + "get": { + "tags": ["System"], + "summary": "Search packages", + "description": "Returns the full @walkeros/* package catalog; clients filter locally.", + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageSearchResponse" + } + } + } }, - "503": { - "description": "No platform LLM provider configured", + "502": { + "description": "Package search unavailable", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LlmConfigStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15097,40 +19154,52 @@ } }, "post": { - "tags": ["Settings"], - "summary": "Set LLM provider", - "description": "Set or clear the project LLM provider override. Admin-only, gated by the chat feature. The apiKey is write-only: it is encrypted and never returned.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], + "tags": ["System"], + "summary": "Log a settled search", + "description": "Records one settled search outcome (the term the user paused on and whether the catalog matched it). Fire-and-forget; returns 204.", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetLlmConfigRequest" + "$ref": "#/components/schemas/PackageSearchLogRequest" } } } }, "responses": { - "200": { - "description": "LLM config saved or cleared", + "204": { + "description": "Search logged" + }, + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetLlmConfigResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } + } + } + } + }, + "/api/observe/timing": { + "post": { + "tags": ["Observe"], + "summary": "Report connect timing", + "description": "Fire-and-forget beacon for client-side connect timing SLIs. No auth required; carries no secrets. Returns 204.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObserveTimingRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Timing recorded" }, "400": { "description": "Validation error", @@ -15141,29 +19210,47 @@ } } } - }, - "401": { - "description": "Unauthorized", + } + } + } + }, + "/api/oauth/register": { + "post": { + "tags": ["OAuth"], + "summary": "Register a client", + "description": "RFC 7591 dynamic client registration. Unauthenticated: a client registers itself before it holds any credential. Issues public clients only (`token_endpoint_auth_method: none`), which prove themselves with PKCE. Errors use the RFC 7591 section 3.2.2 shape, not the standard error envelope.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientRegistrationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Client registered", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthClientRegistrationResponse" } } } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid client metadata or redirect URI", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthRegistrationError" } } } }, - "404": { - "description": "Not found", + "429": { + "description": "Registration ceiling reached (Retry-After header)", "content": { "application/json": { "schema": { @@ -15175,69 +19262,47 @@ } } }, - "/api/projects/{projectId}/chat/sessions": { - "get": { - "tags": ["Chat"], - "summary": "List chat sessions", - "description": "List the caller's recent chat sessions for a project, ordered by last activity. Requires member role and the chat feature.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": ["integer", "null"], - "minimum": 0 - }, - "required": false, - "name": "offset", - "in": "query" + "/api/oauth/device_authorization": { + "post": { + "tags": ["OAuth"], + "summary": "Start a device authorization", + "description": "RFC 8628 section 3.1. A client that cannot host a browser redirect asks for a device code and a user code here, then polls the token endpoint while the person approves the user code at `/oauth/device`. Unauthenticated, and public clients only: the code is worth nothing until a signed-in person approves it. Body is `application/x-www-form-urlencoded`; errors use the RFC 6749 section 5.2 shape, not the standard error envelope.", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Chat session list", + "description": "Device authorization opened", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListChatSessionsResponse" + "$ref": "#/components/schemas/DeviceAuthorizationResponse" } } } }, - "401": { - "description": "Unauthorized", + "400": { + "description": "invalid_request, unauthorized_client, invalid_scope or invalid_target", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthError" } } } }, - "403": { - "description": "Forbidden", + "401": { + "description": "invalid_client: unknown, revoked or confidential client", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthError" } } } @@ -15245,45 +19310,53 @@ } } }, - "/api/projects/{projectId}/chat/sessions/{sessionId}": { - "get": { - "tags": ["Chat"], - "summary": "Get chat session", - "description": "Return a chat session and its full message history when the caller owns it. Foreign or unknown sessions return 404 (never 403) so existence is not leaked. Requires member role and the chat feature.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - }, - { - "schema": { - "type": "string", - "example": "sess_abc123" - }, - "required": true, - "name": "sessionId", - "in": "path" + "/api/oauth/token": { + "post": { + "tags": ["OAuth"], + "summary": "Exchange a grant for tokens", + "description": "RFC 6749 section 3.2. Runs the authorization code, refresh token and device code grants. The client authenticates here: a public client with PKCE, a confidential one with HTTP Basic or a form secret. Body is `application/x-www-form-urlencoded` only; errors use the RFC 6749 section 5.2 shape, not the standard error envelope, and a failed Basic authentication is answered with a `WWW-Authenticate: Basic` challenge. Responses are never cacheable. Rate limited per `client_id`.", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } } - ], + }, "responses": { "200": { - "description": "Chat session with messages", + "description": "Tokens issued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChatSessionDetailResponse" + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "400": { + "description": "invalid_request, invalid_grant, invalid_scope, invalid_target, unsupported_grant_type, or a device grant status (authorization_pending, slow_down, access_denied, expired_token)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" } } } }, "401": { - "description": "Unauthorized", + "description": "invalid_client: unknown, revoked, or bad credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "429": { + "description": "Per-client token budget reached (Retry-After header)", "content": { "application/json": { "schema": { @@ -15291,23 +19364,44 @@ } } } + } + } + } + }, + "/api/oauth/revoke": { + "post": { + "tags": ["OAuth"], + "summary": "Revoke a token", + "description": "RFC 7009. Client authentication is the same as at the token endpoint. A refresh token revokes its whole rotation family, an access token only itself. An authenticated request always answers 200 with an empty body, unknown tokens included: a distinguishable answer would be an oracle. Body is `application/x-www-form-urlencoded`.", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RevocationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Revoked, or nothing matched" }, - "403": { - "description": "Forbidden", + "400": { + "description": "invalid_request or unsupported_token_type", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthError" } } } }, - "404": { - "description": "Not found", + "401": { + "description": "invalid_client: unknown, revoked, or bad credentials", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/OAuthError" } } } @@ -15315,39 +19409,27 @@ } } }, - "/api/projects/{projectId}/chat/elicit": { + "/api/oauth/device/approve": { "post": { - "tags": ["Chat"], - "summary": "Answer elicitation prompt", - "description": "Answer a pending MCP elicitation prompt (accept, decline, or cancel), unblocking the waiting tool invocation. Requires member role and the chat feature.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], + "tags": ["OAuth"], + "summary": "Decide a device authorization", + "description": "The person's approve or deny decision on a pending device authorization. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token can never approve its own device. Requires the `X-CSRF-Token` minted with the consent page, bound to this user code.", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ElicitRequest" + "$ref": "#/components/schemas/DeviceApprovalRequest" } } } }, "responses": { "200": { - "description": "Elicitation resolved", + "description": "Decision recorded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ElicitResponse" + "$ref": "#/components/schemas/DeviceApprovalResponse" } } } @@ -15395,18 +19477,37 @@ } } }, - "/api/mcp/tokens": { - "get": { - "tags": ["Tokens"], - "summary": "List MCP tokens", - "description": "List the authenticated user's personal MCP tokens. No secret material is returned.", + "/api/oauth/authorize": { + "post": { + "tags": ["OAuth"], + "summary": "Decide a consent request", + "description": "The person's allow or deny decision on the consent screen at `/oauth/authorize`. The ticket is the HMAC-signed authorization request that screen was rendered from, so the decision cannot alter what was validated, and it is bound to the person it was minted for. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token can never approve a consent. The response says where to send the browser: the client's registered redirect URI, carrying `code` on allow and `error=access_denied` on deny.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConsentDecisionRequest" + } + } + } + }, "responses": { "200": { - "description": "MCP token list", + "description": "Decision recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthConsentDecisionResponse" + } + } + } + }, + "400": { + "description": "Validation error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListMcpTokensResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -15422,33 +19523,26 @@ } } } - }, - "post": { - "tags": ["Tokens"], - "summary": "Issue MCP token", - "description": "Issue a personal MCP token. The raw token is returned exactly once and is never retrievable afterwards.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMcpTokenRequest" - } - } - } - }, + } + }, + "/api/oauth/grants": { + "get": { + "tags": ["OAuth"], + "summary": "List connected apps", + "description": "The apps the signed-in person has consented to, as the Connected apps page renders them. Revoked grants are absent. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token cannot read the connections its owner holds.", "responses": { - "201": { - "description": "MCP token issued", + "200": { + "description": "Connected apps", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateMcpTokenResponse" + "$ref": "#/components/schemas/ListOAuthGrantsResponse" } } } }, - "400": { - "description": "Validation error", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -15456,6 +19550,16 @@ } } } + } + } + }, + "delete": { + "tags": ["OAuth"], + "summary": "Disconnect every app", + "description": "Revoke every grant this person holds and the tokens hanging from them. Automation tokens hang from no grant and survive. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a read-scoped machine token cannot disconnect everything its owner has connected.", + "responses": { + "204": { + "description": "Apps disconnected" }, "401": { "description": "Unauthorized", @@ -15470,25 +19574,25 @@ } } }, - "/api/mcp/tokens/{tokenId}": { + "/api/oauth/grants/{grantId}": { "delete": { - "tags": ["Tokens"], - "summary": "Revoke MCP token", - "description": "Revoke a personal MCP token by id.", + "tags": ["OAuth"], + "summary": "Disconnect one app", + "description": "Revoke one grant and the tokens hanging from it. Idempotent: an unknown grant, another person's grant and an already revoked one all answer 204, and the token sweep runs either way, so pressing Disconnect twice cleans up a token minted inside the first press's window. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`.", "parameters": [ { "schema": { "type": "string", - "example": "mcptok_abc123" + "example": "grant_abc123" }, "required": true, - "name": "tokenId", + "name": "grantId", "in": "path" } ], "responses": { "204": { - "description": "MCP token revoked" + "description": "App disconnected" }, "401": { "description": "Unauthorized", @@ -15503,30 +19607,18 @@ } } }, - "/api/projects/{projectId}/runners": { + "/api/admin/oauth/clients": { "get": { - "tags": ["Deployments"], - "summary": "List runners (deprecated)", - "description": "Deprecated: runners migrated to deployments (origin=self-hosted). Always returns an empty list for backward compatibility. Requires member role.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], + "tags": ["OAuth"], + "summary": "List OAuth clients", + "description": "Every registered OAuth client, revoked ones included. No secret material is returned. Admin only: a non-admin caller gets 404, not 403, so the endpoint does not confirm its own existence.", "responses": { "200": { - "description": "Empty runner list", + "description": "OAuth client list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListRunnersResponse" + "$ref": "#/components/schemas/ListOAuthClientsResponse" } } } @@ -15541,8 +19633,8 @@ } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -15552,41 +19644,27 @@ } } } - } - }, - "/api/projects/{projectId}/runners/heartbeat": { + }, "post": { - "tags": ["Deployments"], - "summary": "Runner heartbeat", - "description": "Accept a self-hosted runner heartbeat with usage counters. Authenticated by a flow+deployment-bound runner token. Updates deployment liveness and records an immutable usage row.", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^proj_[a-zA-Z0-9_-]+$", - "example": "proj_x7y8z9" - }, - "required": true, - "name": "projectId", - "in": "path" - } - ], + "tags": ["OAuth"], + "summary": "Create a confidential OAuth client", + "description": "Create an OAuth client that authenticates with a secret. The raw secret is returned exactly once and is never retrievable afterwards. Admin only: a non-admin caller gets 404, not 403.", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HeartbeatRequest" + "$ref": "#/components/schemas/CreateOAuthClientRequest" } } } }, "responses": { - "200": { - "description": "Heartbeat accepted", + "201": { + "description": "Client created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RunnerHeartbeatResponse" + "$ref": "#/components/schemas/CreateOAuthClientResponse" } } } @@ -15624,114 +19702,28 @@ } } }, - "/api/packages": { - "get": { - "tags": ["System"], - "summary": "Package catalog", - "description": "Resolved `@walkeros/*` package catalog for the add-step picker, optionally filtered by type and platform.", + "/api/admin/oauth/clients/{clientId}": { + "delete": { + "tags": ["OAuth"], + "summary": "Revoke an OAuth client", + "description": "Revoke a client together with the grants consented to it and the tokens minted under them. Admin only: a non-admin caller gets 404, not 403, the same answer an unknown client id gets.", "parameters": [ { "schema": { "type": "string", - "description": "Filter by package type." - }, - "required": false, - "description": "Filter by package type.", - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Filter by platform." + "example": "client_abc123" }, - "required": false, - "description": "Filter by platform.", - "name": "platform", - "in": "query" + "required": true, + "name": "clientId", + "in": "path" } ], - "responses": { - "200": { - "description": "Package catalog", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageCatalogResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "502": { - "description": "Package catalog unavailable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/packages/search": { - "get": { - "tags": ["System"], - "summary": "Search packages", - "description": "Returns the full @walkeros/* package catalog; clients filter locally.", - "responses": { - "200": { - "description": "Search results", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageSearchResponse" - } - } - } - }, - "502": { - "description": "Package search unavailable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "post": { - "tags": ["System"], - "summary": "Log a settled search", - "description": "Records one settled search outcome (the term the user paused on and whether the catalog matched it). Fire-and-forget; returns 204.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageSearchLogRequest" - } - } - } - }, "responses": { "204": { - "description": "Search logged" + "description": "Client revoked" }, - "400": { - "description": "Validation error", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -15739,30 +19731,9 @@ } } } - } - } - } - }, - "/api/observe/timing": { - "post": { - "tags": ["Observe"], - "summary": "Report connect timing", - "description": "Fire-and-forget beacon for client-side connect timing SLIs. No auth required; carries no secrets. Returns 204.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObserveTimingRequest" - } - } - } - }, - "responses": { - "204": { - "description": "Timing recorded" }, - "400": { - "description": "Validation error", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 069a01ee4..2cab28910 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/cli", - "version": "4.5.0", + "version": "4.6.0", "description": "walkerOS CLI - Bundle and deploy walkerOS components", "license": "MIT", "type": "module", @@ -53,11 +53,11 @@ }, "dependencies": { "@vercel/nft": "^1.10.2", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", - "@walkeros/server-destination-api": "4.5.0", - "@walkeros/transformer-validate": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", + "@walkeros/server-destination-api": "4.6.0", + "@walkeros/transformer-validate": "4.6.0", "ajv": "^8.17.1", "chalk": "^5.6.2", "ci-info": "^4.4.0", @@ -84,8 +84,8 @@ "@types/pacote": "^11.1.8", "@types/picomatch": "4.0.3", "@types/semver": "^7.7.1", - "@walkeros/config": "4.5.0", - "@walkeros/core": "4.5.0", + "@walkeros/config": "4.6.0", + "@walkeros/core": "4.6.0", "msw": "^2.12.10", "openapi-typescript": "^7.13.0", "tsx": "^4.21.0" diff --git a/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts b/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts index a26d7a1c2..cc95b0692 100644 --- a/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts +++ b/packages/cli/src/__tests__/integration/api/api-projects.integration.test.ts @@ -7,7 +7,7 @@ import '../../helpers/setup-msw.js'; jest.mock('../../../core/auth.js', () => ({ - getToken: jest.fn().mockReturnValue('test-token'), + resolveAccessToken: jest.fn().mockResolvedValue('test-token'), requireProjectId: jest.fn().mockReturnValue('proj_test123'), })); jest.mock('../../../lib/config-file.js', () => ({ diff --git a/packages/cli/src/__tests__/unit/commands/frames.test.ts b/packages/cli/src/__tests__/unit/commands/frames.test.ts new file mode 100644 index 000000000..b80f08350 --- /dev/null +++ b/packages/cli/src/__tests__/unit/commands/frames.test.ts @@ -0,0 +1,78 @@ +import { apiFetch } from '../../../core/http.js'; +import { + listFrames, + listPageFrames, + getFrame, +} from '../../../commands/frames/index.js'; + +jest.mock('../../../core/auth.js', () => ({ + ...jest.requireActual('../../../core/auth.js'), + requireProjectId: jest.fn().mockReturnValue('proj_default'), +})); +jest.mock('../../../core/http.js', () => ({ apiFetch: jest.fn() })); + +const mockApiFetch = jest.mocked(apiFetch); +const ok = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status }); + +describe('frames programmatic API', () => { + afterEach(() => jest.clearAllMocks()); + + it('listFrames reads the lean project listing', async () => { + mockApiFetch.mockResolvedValue(ok({ frames: [] })); + await listFrames({ projectId: 'proj_1' }); + expect(mockApiFetch).toHaveBeenCalledWith('/api/projects/proj_1/frames'); + }); + + it('listPageFrames encodes the page key', async () => { + mockApiFetch.mockResolvedValue(ok({ frames: [] })); + await listPageFrames({ + projectId: 'proj_1', + pageKey: 'https://shop.example/cart?x=1', + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/frames?pageKey=https%3A%2F%2Fshop.example%2Fcart%3Fx%3D1', + ); + }); + + it('getFrame reads one frame by id', async () => { + mockApiFetch.mockResolvedValue(ok({ id: 'frm_V1StGXR8Z5jdHi6BmyT7K' })); + await getFrame({ + projectId: 'proj_1', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/frames/frm_V1StGXR8Z5jdHi6BmyT7K', + ); + }); + + it('getFrame throws NOT_FOUND with the wire message', async () => { + mockApiFetch.mockResolvedValue( + ok({ error: { code: 'NOT_FOUND', message: 'Frame not found' } }, 404), + ); + await expect( + getFrame({ projectId: 'proj_1', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }), + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Frame not found', + }); + }); + + it('surfaces FEATURE_NOT_AVAILABLE naming frames', async () => { + mockApiFetch.mockResolvedValue( + ok( + { + error: { + code: 'FEATURE_NOT_AVAILABLE', + message: 'frames is not available on your current plan', + }, + }, + 403, + ), + ); + await expect(listFrames({ projectId: 'proj_1' })).rejects.toMatchObject({ + code: 'FEATURE_NOT_AVAILABLE', + message: 'frames is not available on your current plan', + }); + }); +}); diff --git a/packages/cli/src/__tests__/unit/commands/hub.test.ts b/packages/cli/src/__tests__/unit/commands/hub.test.ts new file mode 100644 index 000000000..b7521e00c --- /dev/null +++ b/packages/cli/src/__tests__/unit/commands/hub.test.ts @@ -0,0 +1,233 @@ +import { requireProjectId } from '../../../core/auth.js'; +import { apiFetch } from '../../../core/http.js'; +import { + listReleases, + getRelease, + listStepHistory, + setReleaseRationale, + listThreads, + createThread, + addThreadMessage, + listKnowledge, +} from '../../../commands/hub/index.js'; +import type { ReleaseRef } from '../../../commands/hub/index.js'; + +jest.mock('../../../core/auth.js', () => ({ + ...jest.requireActual('../../../core/auth.js'), + requireProjectId: jest.fn().mockReturnValue('proj_default'), +})); +jest.mock('../../../core/http.js', () => ({ apiFetch: jest.fn() })); + +const mockApiFetch = jest.mocked(apiFetch); +const mockRequireProjectId = jest.mocked(requireProjectId); + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +describe('hub programmatic API', () => { + afterEach(() => jest.clearAllMocks()); + + it('listReleases asks for the rationale-carrying index', async () => { + mockApiFetch.mockResolvedValue( + ok({ releases: [], total: 0, limit: 20, offset: 0 }), + ); + await listReleases({ + projectId: 'proj_1', + flowId: 'flow_1', + limit: 5, + offset: 10, + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/releases?rationale=true&limit=5&offset=10', + ); + }); + + it('listReleases falls back to the default project', async () => { + mockApiFetch.mockResolvedValue( + ok({ releases: [], total: 0, limit: 20, offset: 0 }), + ); + await listReleases({ flowId: 'flow_1' }); + expect(mockRequireProjectId).toHaveBeenCalled(); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_default/flows/flow_1/releases?rationale=true', + ); + }); + + it.each<[ReleaseRef, string]>([ + [ + { versionId: 'ver_abc' }, + '/api/projects/proj_1/flows/flow_1/releases/ver_abc', + ], + [{ versionNumber: 14 }, '/api/projects/proj_1/flows/flow_1/releases/14'], + ])('getRelease addresses %j on the path', async (ref, path) => { + mockApiFetch.mockResolvedValue( + ok({ versionId: 'ver_abc', versionNumber: 14 }), + ); + await getRelease({ projectId: 'proj_1', flowId: 'flow_1', ref }); + expect(mockApiFetch).toHaveBeenCalledWith(path); + }); + + it('getRelease throws the API error code on a 404', async () => { + mockApiFetch.mockResolvedValue( + ok({ error: { code: 'NOT_FOUND', message: 'Release not found' } }, 404), + ); + await expect( + getRelease({ + projectId: 'proj_1', + flowId: 'flow_1', + ref: { versionId: 'ver_x' }, + }), + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Release not found', + status: 404, + }); + }); + + it('listStepHistory encodes step, flow and limit', async () => { + mockApiFetch.mockResolvedValue( + ok({ step: 'destination.ga4', entries: [] }), + ); + await listStepHistory({ + projectId: 'proj_1', + flowId: 'flow_1', + step: 'destination.ga4', + flow: 'web', + limit: 30, + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/releases/step-history?step=destination.ga4&flow=web&limit=30', + ); + }); + + it('setReleaseRationale PUTs humanText for one versionId', async () => { + mockApiFetch.mockResolvedValue( + ok({ versionId: 'ver_abc', humanText: 'why' }), + ); + await setReleaseRationale({ + projectId: 'proj_1', + flowId: 'flow_1', + versionId: 'ver_abc', + text: 'why', + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/releases/annotations', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ versionId: 'ver_abc', humanText: 'why' }), + }), + ); + }); + + it('setReleaseRationale clears a rationale with a null humanText', async () => { + mockApiFetch.mockResolvedValue( + ok({ versionId: 'ver_abc', humanText: null }), + ); + await setReleaseRationale({ + projectId: 'proj_1', + flowId: 'flow_1', + versionId: 'ver_abc', + text: null, + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/releases/annotations', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ versionId: 'ver_abc', humanText: null }), + }), + ); + }); + + it('listThreads sends includeMessages as the string the route parses', async () => { + mockApiFetch.mockResolvedValue(ok({ threads: [], hasMoreThreads: false })); + await listThreads({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'release', + anchorKey: 'ver_abc', + status: 'open', + includeMessages: true, + limit: 20, + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/threads?anchorType=release&anchorKey=ver_abc&status=open&includeMessages=true&limit=20', + ); + }); + + it('createThread POSTs the anchor and first message', async () => { + mockApiFetch.mockResolvedValue(ok({ id: 'thr_1' }, 201)); + await createThread({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'GA4', + text: 'hi', + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/threads', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'GA4', + text: 'hi', + }), + }), + ); + }); + + it('addThreadMessage POSTs into the thread', async () => { + mockApiFetch.mockResolvedValue(ok({ id: 'thr_1' }, 201)); + await addThreadMessage({ + projectId: 'proj_1', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'reply', + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/flows/flow_1/threads/thr_1/messages', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ text: 'reply' }), + }), + ); + }); + + it('listKnowledge encodes the three narrowings and includeMessages', async () => { + mockApiFetch.mockResolvedValue(ok({ entries: [], hasMoreEntries: false })); + await listKnowledge({ + projectId: 'proj_1', + pageKey: 'https://shop.example/cart', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + markId: 'm1', + includeMessages: true, + limit: 10, + }); + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/projects/proj_1/knowledge?pageKey=https%3A%2F%2Fshop.example%2Fcart&frameId=frm_V1StGXR8Z5jdHi6BmyT7K&markId=m1&includeMessages=true&limit=10', + ); + }); + + it('surfaces FEATURE_NOT_AVAILABLE with the feature-naming message', async () => { + mockApiFetch.mockResolvedValue( + ok( + { + error: { + code: 'FEATURE_NOT_AVAILABLE', + message: 'hub is not available on your current plan', + }, + }, + 403, + ), + ); + await expect( + listKnowledge({ projectId: 'proj_1', includeMessages: false }), + ).rejects.toMatchObject({ + code: 'FEATURE_NOT_AVAILABLE', + message: 'hub is not available on your current plan', + }); + }); +}); diff --git a/packages/cli/src/__tests__/unit/commands/logout.test.ts b/packages/cli/src/__tests__/unit/commands/logout.test.ts new file mode 100644 index 000000000..47631340f --- /dev/null +++ b/packages/cli/src/__tests__/unit/commands/logout.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { logout } from '../../../commands/logout/index.js'; +import { revokeRefreshToken } from '../../../core/oauth-client.js'; +import { readConfig, writeConfig } from '../../../lib/config-file.js'; + +jest.mock('../../../core/oauth-client.js', () => ({ + revokeRefreshToken: jest.fn(async () => undefined), +})); + +const mockRevoke = jest.mocked(revokeRefreshToken); + +describe('logout', () => { + let dir: string; + const originalEnv = process.env; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'walkeros-logout-')); + process.env = { ...originalEnv }; + process.env.XDG_CONFIG_HOME = dir; + delete process.env.WALKEROS_APP_URL; + mockRevoke.mockImplementation(async () => undefined); + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(dir, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + it('revokes the stored refresh token, then deletes the config', async () => { + writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); + + await expect(logout()).resolves.toEqual({ + deleted: true, + superseded: false, + }); + expect(mockRevoke).toHaveBeenCalledWith('https://app.walkeros.io', 'rt_1'); + expect(readConfig()).toBeNull(); + }); + + it('reports nothing to delete when no config exists', async () => { + await expect(logout()).resolves.toEqual({ + deleted: false, + superseded: false, + }); + expect(mockRevoke).not.toHaveBeenCalled(); + }); + + it('keeps a session that was stored while the revocation was in flight', async () => { + writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); + // A login finishing inside the revocation round trip. Deleting the file + // afterwards would take a session this logout never saw. + mockRevoke.mockImplementation(async () => { + writeConfig({ accessToken: 'at_2', refreshToken: 'rt_2' }); + }); + + await expect(logout()).resolves.toEqual({ + deleted: false, + superseded: true, + }); + expect(readConfig()?.refreshToken).toBe('rt_2'); + }); + + it('deletes when the config came back unchanged', async () => { + writeConfig({ accessToken: 'at_1', refreshToken: 'rt_1' }); + // A write that touches something other than the session is not a login. + mockRevoke.mockImplementation(async () => { + writeConfig({ defaultProjectId: 'proj_1' }); + }); + + await expect(logout()).resolves.toEqual({ + deleted: true, + superseded: false, + }); + expect(readConfig()).toBeNull(); + }); +}); diff --git a/packages/cli/src/__tests__/unit/config/fetch-content.test.ts b/packages/cli/src/__tests__/unit/config/fetch-content.test.ts new file mode 100644 index 000000000..2005ccb35 --- /dev/null +++ b/packages/cli/src/__tests__/unit/config/fetch-content.test.ts @@ -0,0 +1,108 @@ +import { fetchContentString } from '../../../config/utils.js'; + +const APP_URL = 'https://app.example.test'; +const TOKEN = 'tok_secret'; + +interface RecordedCall { + url: string; + authorization: string | undefined; +} + +/** A fetch double that records the request line of every call. */ +function recorder() { + const calls: RecordedCall[] = []; + + const fetchFn: typeof fetch = async (input, init) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) Object.assign(headers, Object.fromEntries(raw)); + else if (Array.isArray(raw)) + Object.assign(headers, Object.fromEntries(raw)); + else if (raw) Object.assign(headers, raw); + + calls.push({ url: String(input), authorization: headers.Authorization }); + + return new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + return { fetchFn, calls }; +} + +describe('fetchContentString auth scoping', () => { + const originalEnv = process.env; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.WALKEROS_APP_URL = APP_URL; + // Set in every case below, so the absence of a header is attributable to + // the URL and never to there being no credential to send. + process.env.WALKEROS_TOKEN = TOKEN; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + }); + + it('sends the bearer to the configured app origin', async () => { + const { fetchFn, calls } = recorder(); + globalThis.fetch = fetchFn; + + await fetchContentString(`${APP_URL}/api/flows/fl_1/bundle.js`); + + expect(calls[0]?.authorization).toBe(`Bearer ${TOKEN}`); + }); + + it.each([ + ['a foreign host', 'https://attacker.example/flow.json'], + // Prefix-shaped: it starts with the app URL but is a different origin, so + // only an origin comparison keeps the token off it. + [ + 'a host that merely starts with the app URL', + `${APP_URL}.attacker.example/flow.json`, + ], + // Same host, different port: still a different origin. + ['the app host on another port', 'https://app.example.test:8443/flow.json'], + ])('sends no Authorization header to %s', async (_label, url) => { + const { fetchFn, calls } = recorder(); + globalThis.fetch = fetchFn; + + await fetchContentString(url); + + expect(calls[0]?.url).toBe(url); + expect(calls[0]?.authorization).toBeUndefined(); + }); + + it('refuses to send the bearer to the app over plain http', async () => { + const { fetchFn, calls } = recorder(); + globalThis.fetch = fetchFn; + process.env.WALKEROS_APP_URL = 'http://app.example.test'; + + await expect( + fetchContentString('http://app.example.test/api/flows/fl_1/bundle.js'), + ).rejects.toThrow(/plain http/); + expect(calls).toHaveLength(0); + }); + + it('leaves a plain http URL that carries no bearer alone', async () => { + const { fetchFn, calls } = recorder(); + globalThis.fetch = fetchFn; + + await fetchContentString('http://attacker.example/flow.json'); + + expect(calls[0]?.authorization).toBeUndefined(); + }); + + it('returns the body it fetched', async () => { + const { fetchFn } = recorder(); + globalThis.fetch = fetchFn; + + await expect( + fetchContentString('https://attacker.example/flow.json'), + ).resolves.toBe('{}'); + }); +}); diff --git a/packages/cli/src/__tests__/unit/core/api-client.test.ts b/packages/cli/src/__tests__/unit/core/api-client.test.ts index 0a85ed72e..c46390964 100644 --- a/packages/cli/src/__tests__/unit/core/api-client.test.ts +++ b/packages/cli/src/__tests__/unit/core/api-client.test.ts @@ -1,21 +1,40 @@ import { createApiClient } from '../../../core/api-client.js'; -import { getToken } from '../../../core/auth.js'; +import { resolveAccessToken } from '../../../core/auth.js'; +import { resolveAppUrl } from '../../../lib/config-file.js'; jest.mock('../../../core/auth.js', () => ({ - getToken: jest.fn(), + resolveAccessToken: jest.fn(), })); jest.mock('../../../lib/config-file.js', () => ({ resolveAppUrl: jest.fn().mockReturnValue('https://app.walkeros.io'), })); -const mockGetToken = jest.mocked(getToken); +const mockResolveAccessToken = jest.mocked(resolveAccessToken); +const mockResolveAppUrl = jest.mocked(resolveAppUrl); describe('createApiClient', () => { - afterEach(() => jest.clearAllMocks()); + const originalFetch = global.fetch; + let sentAuthorization: Array; + + beforeEach(() => { + sentAuthorization = []; + global.fetch = jest.fn(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : null; + sentAuthorization.push(request?.headers.get('authorization') ?? null); + return new Response(JSON.stringify({ projects: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); it('creates a client with GET and POST methods', () => { - mockGetToken.mockReturnValue('sk-walkeros-test'); const client = createApiClient(); expect(client).toBeDefined(); expect(typeof client.GET).toBe('function'); @@ -24,8 +43,62 @@ describe('createApiClient', () => { expect(typeof client.DELETE).toBe('function'); }); - it('throws when no token available', () => { - mockGetToken.mockReturnValue(undefined); - expect(() => createApiClient()).toThrow('WALKEROS_TOKEN not set'); + it('does not resolve a token until a request is made', () => { + createApiClient(); + expect(mockResolveAccessToken).not.toHaveBeenCalled(); + }); + + it('attaches the resolved bearer to the outgoing request', async () => { + mockResolveAccessToken.mockResolvedValue('at_first'); + const client = createApiClient(); + + await client.GET('/api/projects'); + + expect(sentAuthorization).toEqual(['Bearer at_first']); + }); + + it('resolves the token per request, so a refresh reaches a long-lived client', async () => { + // The stdio MCP server builds one client and keeps it for hours. A token + // captured at construction would go stale and never recover. + mockResolveAccessToken + .mockResolvedValueOnce('at_first') + .mockResolvedValueOnce('at_refreshed'); + const client = createApiClient(); + + await client.GET('/api/projects'); + await client.GET('/api/projects'); + + expect(sentAuthorization).toEqual([ + 'Bearer at_first', + 'Bearer at_refreshed', + ]); + }); + + it('throws when no token resolves', async () => { + mockResolveAccessToken.mockResolvedValue(null); + const client = createApiClient(); + + await expect(client.GET('/api/projects')).rejects.toThrow( + 'Not authenticated', + ); + }); + + it('refuses to send the bearer over plain http off the local machine', async () => { + mockResolveAccessToken.mockResolvedValue('at_first'); + mockResolveAppUrl.mockReturnValueOnce('http://app.walkeros.io'); + const client = createApiClient(); + + await expect(client.GET('/api/projects')).rejects.toThrow(/plain http/); + expect(sentAuthorization).toEqual([]); + }); + + it('allows a loopback app URL over plain http', async () => { + mockResolveAccessToken.mockResolvedValue('at_first'); + mockResolveAppUrl.mockReturnValueOnce('http://localhost:3000'); + const client = createApiClient(); + + await client.GET('/api/projects'); + + expect(sentAuthorization).toEqual(['Bearer at_first']); }); }); diff --git a/packages/cli/src/__tests__/unit/core/auth.test.ts b/packages/cli/src/__tests__/unit/core/auth.test.ts index 76b25b1a8..67a1545bd 100644 --- a/packages/cli/src/__tests__/unit/core/auth.test.ts +++ b/packages/cli/src/__tests__/unit/core/auth.test.ts @@ -1,73 +1,348 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { - getToken, + resolveAccessToken, getAuthHeaders, + credentialSource, resolveRunToken, requireProjectId, + resetLegacyTokenNotice, } from '../../../core/auth.js'; -import { getDefaultProject } from '../../../lib/config-file.js'; - -// Isolate from real ~/.config/walkeros/config.json -jest.mock('../../../lib/config-file.js', () => ({ - resolveToken: () => { - const token = process.env.WALKEROS_TOKEN; - if (!token) return null; - return { token, source: 'env' as const }; - }, - resolveDeployToken: () => process.env.WALKEROS_DEPLOY_TOKEN ?? null, - resolveAppUrl: () => - process.env.WALKEROS_APP_URL || 'https://app.walkeros.io', - getDefaultProject: jest.fn(() => null), -})); - -const mockGetDefaultProject = getDefaultProject as jest.MockedFunction< - typeof getDefaultProject ->; - -describe('auth', () => { +import { readConfig, writeConfig } from '../../../lib/config-file.js'; +import { withConfigLock } from '../../../lib/config-lock.js'; + +const APP_URL = 'https://app.example.test'; +const NOW = Date.parse('2026-09-07T12:00:00.000Z'); + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * A fetch double that fails the test if it is called at all, while recording + * the attempt so the assertion names what happened rather than the throw. + */ +function forbiddenFetch() { + const calls: string[] = []; + const fetchFn: typeof fetch = async (input) => { + calls.push(String(input)); + throw new Error(`unexpected network call to ${String(input)}`); + }; + return { fetchFn, calls }; +} + +function tokenResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('core/auth', () => { + let dir: string; const originalEnv = process.env; beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'walkeros-auth-')); process.env = { ...originalEnv }; + process.env.XDG_CONFIG_HOME = dir; delete process.env.WALKEROS_TOKEN; delete process.env.WALKEROS_DEPLOY_TOKEN; delete process.env.WALKEROS_PROJECT_ID; - mockGetDefaultProject.mockReturnValue(null); + process.env.WALKEROS_APP_URL = APP_URL; + resetLegacyTokenNotice(); }); afterEach(() => { process.env = originalEnv; + rmSync(dir, { recursive: true, force: true }); jest.restoreAllMocks(); }); - describe('getToken', () => { - it('returns undefined when WALKEROS_TOKEN is not set', () => { - expect(getToken()).toBeUndefined(); + describe('resolveAccessToken', () => { + it('returns null when nothing is configured', async () => { + await expect(resolveAccessToken()).resolves.toBeNull(); }); - it('returns token when WALKEROS_TOKEN is set', () => { - process.env.WALKEROS_TOKEN = 'sk-walkeros-abc123'; - expect(getToken()).toBe('sk-walkeros-abc123'); + it('prefers WALKEROS_TOKEN over a stored session', async () => { + writeConfig({ + accessToken: 'at_config', + accessTokenExpiresAt: iso(NOW + 3600_000), + refreshToken: 'rt_1', + }); + process.env.WALKEROS_TOKEN = 'env-token'; + + await expect(resolveAccessToken({ now: () => NOW })).resolves.toBe( + 'env-token', + ); }); - it('returns undefined for empty string', () => { - process.env.WALKEROS_TOKEN = ''; - expect(getToken()).toBeUndefined(); + it('returns the stored session when WALKEROS_TOKEN is absent', async () => { + // The control for the precedence test above: proves "env wins" is about + // priority, not about the stored session being unreadable. + writeConfig({ + accessToken: 'at_config', + accessTokenExpiresAt: iso(NOW + 3600_000), + refreshToken: 'rt_1', + }); + + await expect(resolveAccessToken({ now: () => NOW })).resolves.toBe( + 'at_config', + ); + }); + + it('honors a legacy static token', async () => { + writeConfig({ token: 'legacy-token' }); + jest.spyOn(process.stderr, 'write').mockReturnValue(true); + + await expect(resolveAccessToken()).resolves.toBe('legacy-token'); + }); + + it('logs the legacy deprecation notice exactly once per process', async () => { + writeConfig({ token: 'legacy-token' }); + const write = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + + await resolveAccessToken(); + await resolveAccessToken(); + + const notices = write.mock.calls.filter((call) => + String(call[0]).includes('walkeros auth login'), + ); + expect(notices).toHaveLength(1); + expect(String(notices[0]?.[0])).toContain('walkeros auth login'); + }); + + it('returns a fresh access token without any network call', async () => { + writeConfig({ + accessToken: 'at_fresh', + accessTokenExpiresAt: iso(NOW + 3600_000), + refreshToken: 'rt_1', + }); + const { fetchFn, calls } = forbiddenFetch(); + + await expect( + resolveAccessToken({ fetch: fetchFn, now: () => NOW }), + ).resolves.toBe('at_fresh'); + expect(calls).toEqual([]); + }); + + it('refreshes an access token inside the sixty second window', async () => { + // 30 s of life left is inside the 60 s skew window, so it counts as stale + // even though it has not formally expired. + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW + 30_000), + refreshToken: 'rt_old', + }); + let hits = 0; + const fetchFn: typeof fetch = async () => { + hits += 1; + return tokenResponse({ + access_token: 'at_rotated', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt_rotated', + }); + }; + + await expect( + resolveAccessToken({ fetch: fetchFn, now: () => NOW }), + ).resolves.toBe('at_rotated'); + + expect(hits).toBe(1); + const stored = readConfig(); + expect(stored?.accessToken).toBe('at_rotated'); + expect(stored?.refreshToken).toBe('rt_rotated'); + expect(Date.parse(stored?.accessTokenExpiresAt ?? '')).toBeGreaterThan( + NOW + 3000_000, + ); + }); + + it('keeps the existing refresh token when the server rotates none', async () => { + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW - 1000), + refreshToken: 'rt_keep', + }); + const fetchFn: typeof fetch = async () => + tokenResponse({ + access_token: 'at_rotated', + token_type: 'Bearer', + expires_in: 3600, + }); + + await resolveAccessToken({ fetch: fetchFn, now: () => NOW }); + + expect(readConfig()?.refreshToken).toBe('rt_keep'); + }); + + it('reuses a session another process refreshed while we waited for the lock, without a network call', async () => { + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW + 10_000), + refreshToken: 'rt_old', + }); + const { fetchFn, calls } = forbiddenFetch(); + + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let acquired!: () => void; + const isHeld = new Promise((resolve) => { + acquired = resolve; + }); + + // Stand in for a second walkerOS process that holds the lock and + // refreshes the session while this one waits behind it. + const holder = withConfigLock(async () => { + acquired(); + await held; + writeConfig({ + accessToken: 'at_by_other_process', + accessTokenExpiresAt: iso(NOW + 3600_000), + refreshToken: 'rt_by_other_process', + }); + }); + await isHeld; + + const pending = resolveAccessToken({ fetch: fetchFn, now: () => NOW }); + await wait(150); + release(); + await holder; + + await expect(pending).resolves.toBe('at_by_other_process'); + expect(calls).toEqual([]); + }); + + it('clears the stored session and returns null when the refresh token is rejected', async () => { + writeConfig({ + email: 'user@example.test', + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW - 1000), + refreshToken: 'rt_dead', + defaultProjectId: 'proj_keep', + }); + const fetchFn: typeof fetch = async () => + tokenResponse( + { error: 'invalid_grant', error_description: 'dead' }, + 400, + ); + + await expect( + resolveAccessToken({ fetch: fetchFn, now: () => NOW }), + ).resolves.toBeNull(); + + const stored = readConfig(); + expect(stored?.accessToken).toBeUndefined(); + expect(stored?.refreshToken).toBeUndefined(); + expect(stored?.email).toBeUndefined(); + expect(stored?.defaultProjectId).toBe('proj_keep'); + }); + + it('keeps the stored session when the refresh fails for a transient reason', async () => { + // The control for the clearing test: a network blip must not log the + // person out, only a server verdict on the refresh token itself may. + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW - 1000), + refreshToken: 'rt_alive', + }); + const fetchFn: typeof fetch = async () => { + throw new Error('network unreachable'); + }; + + await expect( + resolveAccessToken({ fetch: fetchFn, now: () => NOW }), + ).rejects.toThrow('network unreachable'); + + expect(readConfig()?.refreshToken).toBe('rt_alive'); + }); + + it('names the connection, not the session, when the refresh cannot be sent', async () => { + // Null is how this function says "no session", which makes callers tell + // the person to log in and send the request unauthenticated. An + // unreachable server must not be reported that way. + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW - 1000), + refreshToken: 'rt_alive', + }); + const fetchFn: typeof fetch = async () => { + throw new Error('network unreachable'); + }; + + const failure = await resolveAccessToken({ + fetch: fetchFn, + now: () => NOW, + }).catch((error: unknown) => + error instanceof Error ? error.message : String(error), + ); + + expect(failure).toContain('Could not reach'); + expect(failure).toContain(APP_URL); + expect(failure).toContain('session was kept'); + expect(failure).not.toContain('walkeros auth login'); + }); + + it('returns null for an expired session with no refresh token', async () => { + writeConfig({ + accessToken: 'at_stale', + accessTokenExpiresAt: iso(NOW - 1000), + }); + const { fetchFn, calls } = forbiddenFetch(); + + await expect( + resolveAccessToken({ fetch: fetchFn, now: () => NOW }), + ).resolves.toBeNull(); + expect(calls).toEqual([]); }); }); describe('getAuthHeaders', () => { - it('returns empty object when no token', () => { - expect(getAuthHeaders()).toEqual({}); + it('returns an empty object when nothing is configured', async () => { + await expect(getAuthHeaders()).resolves.toEqual({}); }); - it('returns Authorization header when token is set', () => { + it('returns a bearer header for the resolved token', async () => { process.env.WALKEROS_TOKEN = 'sk-walkeros-test'; - expect(getAuthHeaders()).toEqual({ + await expect(getAuthHeaders()).resolves.toEqual({ Authorization: 'Bearer sk-walkeros-test', }); }); }); + describe('credentialSource', () => { + it('returns null when nothing is configured', () => { + expect(credentialSource()).toBeNull(); + }); + + it('returns env for WALKEROS_TOKEN', () => { + process.env.WALKEROS_TOKEN = 'env-token'; + expect(credentialSource()).toBe('env'); + }); + + it('returns config for a stored session', () => { + writeConfig({ accessToken: 'at', accessTokenExpiresAt: iso(NOW) }); + expect(credentialSource()).toBe('config'); + }); + + it('returns config for a legacy static token', () => { + writeConfig({ token: 'legacy' }); + expect(credentialSource()).toBe('config'); + }); + + it('returns null for a config that holds no credential at all', () => { + writeConfig({ defaultProjectId: 'proj_1' }); + expect(credentialSource()).toBeNull(); + }); + }); + describe('resolveRunToken', () => { it('returns WALKEROS_DEPLOY_TOKEN when set', () => { process.env.WALKEROS_DEPLOY_TOKEN = 'deploy-token'; @@ -83,6 +358,15 @@ describe('auth', () => { it('returns null when no token available', () => { expect(resolveRunToken()).toBeNull(); }); + + it('ignores a refreshable session, which a runner cannot refresh', () => { + writeConfig({ + accessToken: 'at', + accessTokenExpiresAt: iso(NOW + 3600_000), + refreshToken: 'rt', + }); + expect(resolveRunToken()).toBeNull(); + }); }); describe('requireProjectId', () => { @@ -92,13 +376,13 @@ describe('auth', () => { }); it('returns config defaultProjectId when env var not set', () => { - mockGetDefaultProject.mockReturnValue('proj-from-config'); + writeConfig({ defaultProjectId: 'proj-from-config' }); expect(requireProjectId()).toBe('proj-from-config'); }); it('prefers env var over config when both set', () => { process.env.WALKEROS_PROJECT_ID = 'proj-from-env'; - mockGetDefaultProject.mockReturnValue('proj-from-config'); + writeConfig({ defaultProjectId: 'proj-from-config' }); expect(requireProjectId()).toBe('proj-from-env'); }); diff --git a/packages/cli/src/__tests__/unit/core/http.test.ts b/packages/cli/src/__tests__/unit/core/http.test.ts index f179e15d0..a9f8b6090 100644 --- a/packages/cli/src/__tests__/unit/core/http.test.ts +++ b/packages/cli/src/__tests__/unit/core/http.test.ts @@ -7,13 +7,18 @@ import { jest.mock('../../../lib/config-file.js', () => ({ resolveAppUrl: jest.fn().mockReturnValue('https://stage.app.walkeros.io'), - resolveToken: jest - .fn() - .mockReturnValue({ token: 'test-token', source: 'env' }), resolveDeployToken: jest.fn().mockReturnValue(null), })); -import { resolveDeployToken } from '../../../lib/config-file.js'; +jest.mock('../../../core/auth.js', () => ({ + resolveAccessToken: jest.fn().mockResolvedValue('test-token'), +})); + +import { resolveAppUrl, resolveDeployToken } from '../../../lib/config-file.js'; +import { resolveAccessToken } from '../../../core/auth.js'; + +const mockResolveAccessToken = jest.mocked(resolveAccessToken); +const mockResolveAppUrl = jest.mocked(resolveAppUrl); describe('core/http', () => { const originalFetch = global.fetch; @@ -22,6 +27,7 @@ describe('core/http', () => { beforeEach(() => { mockFetch = jest.fn().mockResolvedValue({ ok: true }); global.fetch = mockFetch; + mockResolveAccessToken.mockResolvedValue('test-token'); }); afterEach(() => { @@ -44,6 +50,24 @@ describe('core/http', () => { ); }); + it('refuses to send the bearer over plain http off the local machine', async () => { + mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); + + await expect(apiFetch('/api/feedback')).rejects.toThrow(/plain http/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('allows a loopback app URL over plain http', async () => { + mockResolveAppUrl.mockReturnValueOnce('http://localhost:3000'); + + await apiFetch('/api/feedback'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/feedback', + expect.anything(), + ); + }); + it('preserves existing headers', async () => { await apiFetch('/api/test', { headers: { 'X-Custom': 'value' }, @@ -53,28 +77,69 @@ describe('core/http', () => { expect(headers['X-Custom']).toBe('value'); expect(headers['Authorization']).toBe('Bearer test-token'); }); + + it('sends no auth header when no token resolves', async () => { + mockResolveAccessToken.mockResolvedValue(null); + + await apiFetch('/api/test'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('awaits the resolved token rather than embedding the promise', async () => { + // A missed `await` would stringify a Promise into the header, which is a + // silent auth failure rather than a type error once spread into an object. + await apiFetch('/api/test'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(String(headers['Authorization'])).not.toContain('Promise'); + }); }); describe('publicFetch', () => { it('prepends base URL without auth header', async () => { - await publicFetch('/api/auth/device/code', { + await publicFetch('/api/oauth/device_authorization', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); expect(mockFetch).toHaveBeenCalledWith( - 'https://stage.app.walkeros.io/api/auth/device/code', + 'https://stage.app.walkeros.io/api/oauth/device_authorization', expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }), ); + expect(mockResolveAccessToken).not.toHaveBeenCalled(); + }); + + it('carries no credential, so plain http is the caller\u2019s to choose', async () => { + mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); + + await publicFetch('/api/oauth/device_authorization'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://stage.app.walkeros.io/api/oauth/device_authorization', + expect.anything(), + ); }); }); describe('deployFetch', () => { + it('refuses to send the deploy token over plain http off the local machine', async () => { + jest.mocked(resolveDeployToken).mockReturnValueOnce('deploy-tok'); + mockResolveAppUrl.mockReturnValueOnce('http://stage.app.walkeros.io'); + + await expect(deployFetch('/api/projects/p1/x')).rejects.toThrow( + /plain http/, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('uses deploy token when available', async () => { - (resolveDeployToken as jest.Mock).mockReturnValueOnce('deploy-tok'); + jest.mocked(resolveDeployToken).mockReturnValueOnce('deploy-tok'); await deployFetch('/api/projects/p1/runners/heartbeat', { method: 'POST', @@ -92,6 +157,14 @@ describe('core/http', () => { const headers = mockFetch.mock.calls[0][1].headers; expect(headers['Authorization']).toBe('Bearer test-token'); }); + + it('throws when neither a deploy token nor a session resolves', async () => { + mockResolveAccessToken.mockResolvedValue(null); + + await expect( + deployFetch('/api/projects/p1/runners/heartbeat'), + ).rejects.toThrow('No authentication token available'); + }); }); describe('mergeAuthHeaders', () => { diff --git a/packages/cli/src/__tests__/unit/core/oauth-client.test.ts b/packages/cli/src/__tests__/unit/core/oauth-client.test.ts new file mode 100644 index 000000000..62cbcb565 --- /dev/null +++ b/packages/cli/src/__tests__/unit/core/oauth-client.test.ts @@ -0,0 +1,448 @@ +import { + CLI_CLIENT_ID, + CLI_SCOPE, + startDeviceAuthorization, + pollDeviceToken, + refreshTokens, + revokeRefreshToken, +} from '../../../core/oauth-client.js'; + +const APP_URL = 'https://app.example.test'; + +/** The same app, reached over plain http: a remote host, so never allowed. */ +const HTTP_URL = 'http://app.example.test'; + +/** A fetch that must never be called; every case here refuses before sending. */ +const F = (): typeof fetch => recorder(() => jsonResponse({})).fetchFn; +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +/** `withConfigLock` treats a lock held longer than this as abandoned. */ +const LOCK_STALE_MS = 15_000; + +interface RecordedCall { + url: string; + method: string | undefined; + contentType: string | undefined; + form: Record; + signal: AbortSignal | null | undefined; + redirect: RequestRedirect | undefined; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function oauthError(error: string, status = 400): Response { + return jsonResponse( + { error, error_description: `${error} happened` }, + status, + ); +} + +/** + * A fetch double that records the request line of every call and answers with + * the next queued response factory. Factories, not responses, because a + * `Response` body can only be read once. + */ +function recorder(...responses: Array<() => Response>) { + const calls: RecordedCall[] = []; + let index = 0; + + const fetchFn: typeof fetch = async (input, init) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) Object.assign(headers, Object.fromEntries(raw)); + else if (Array.isArray(raw)) + Object.assign(headers, Object.fromEntries(raw)); + else if (raw) Object.assign(headers, raw); + + const body = typeof init?.body === 'string' ? init.body : ''; + calls.push({ + url: String(input), + method: init?.method, + contentType: headers['Content-Type'] ?? headers['content-type'], + form: Object.fromEntries(new URLSearchParams(body)), + signal: init?.signal, + redirect: init?.redirect, + }); + + const next = responses[index] ?? responses[responses.length - 1]; + index += 1; + if (!next) throw new Error('recorder has no configured response'); + return next(); + }; + + return { fetchFn, calls }; +} + +function tokenBody(overrides: Record = {}) { + return { + access_token: 'at_new', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt_new', + ...overrides, + }; +} + +describe('oauth-client', () => { + describe('startDeviceAuthorization', () => { + it('posts a form-encoded client_id, scope and api resource', async () => { + const { fetchFn, calls } = recorder(() => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, + expires_in: 600, + interval: 5, + }), + ); + + await startDeviceAuthorization(APP_URL, fetchFn); + + expect(calls).toHaveLength(1); + const call = calls[0]; + expect(call?.url).toBe(`${APP_URL}/api/oauth/device_authorization`); + expect(call?.method).toBe('POST'); + expect(call?.contentType).toBe('application/x-www-form-urlencoded'); + expect(call?.form).toEqual({ + client_id: CLI_CLIENT_ID, + scope: CLI_SCOPE, + resource: `${APP_URL}/api`, + }); + }); + + it('bounds the request with a signal, so a silent server cannot hold it', async () => { + const { fetchFn, calls } = recorder(() => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + expires_in: 600, + interval: 5, + }), + ); + + await startDeviceAuthorization(APP_URL, fetchFn); + + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); + }); + + it('maps the snake_case response onto the camelCase result', async () => { + const { fetchFn } = recorder(() => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, + expires_in: 600, + interval: 5, + }), + ); + + await expect(startDeviceAuthorization(APP_URL, fetchFn)).resolves.toEqual( + { + deviceCode: 'dc_1', + userCode: 'ABCD-EFGH', + verificationUri: `${APP_URL}/oauth/device`, + verificationUriComplete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, + expiresIn: 600, + interval: 5, + }, + ); + }); + + it('falls back to verification_uri when the server omits the complete form', async () => { + const { fetchFn } = recorder(() => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + expires_in: 600, + interval: 5, + }), + ); + + const result = await startDeviceAuthorization(APP_URL, fetchFn); + expect(result.verificationUriComplete).toBe(`${APP_URL}/oauth/device`); + }); + + it('throws on an error status', async () => { + const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); + await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( + /invalid_client/, + ); + }); + + it('throws when the response is missing device_code', async () => { + const { fetchFn } = recorder(() => + jsonResponse({ + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + expires_in: 600, + interval: 5, + }), + ); + + await expect(startDeviceAuthorization(APP_URL, fetchFn)).rejects.toThrow( + /device authorization response/i, + ); + }); + }); + + describe('pollDeviceToken', () => { + it('posts the device grant with the device code and client id', async () => { + const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); + + await pollDeviceToken(APP_URL, 'dc_1', fetchFn); + + const call = calls[0]; + expect(call?.url).toBe(`${APP_URL}/api/oauth/token`); + expect(call?.method).toBe('POST'); + expect(call?.contentType).toBe('application/x-www-form-urlencoded'); + expect(call?.form).toEqual({ + grant_type: DEVICE_GRANT, + device_code: 'dc_1', + client_id: CLI_CLIENT_ID, + }); + }); + + it('returns the token set with an absolute expiry on success', async () => { + const { fetchFn } = recorder(() => jsonResponse(tokenBody())); + const before = Date.now(); + + const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') throw new Error('expected ok'); + expect(result.tokens.accessToken).toBe('at_new'); + expect(result.tokens.refreshToken).toBe('rt_new'); + const expiresAt = Date.parse(result.tokens.accessTokenExpiresAt); + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 3600_000); + }); + + it.each([ + ['authorization_pending', 'pending'], + ['slow_down', 'slow_down'], + ['access_denied', 'denied'], + ['expired_token', 'expired'], + ])('maps the %s error onto status %s', async (code, status) => { + const { fetchFn } = recorder(() => oauthError(code)); + const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); + expect(result.status).toBe(status); + }); + + it('reports an unrecognized error code as an error rather than pending', async () => { + // The control for the mapping table: an unknown code must not silently + // fall into one of the retryable buckets and poll forever. + const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); + const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); + + expect(result.status).toBe('error'); + if (result.status !== 'error') throw new Error('expected error'); + // Both halves reach the caller: the code names the fault, the + // description is the only part that tells the person what to do. + expect(result.error).toBe('invalid_client: invalid_client happened'); + }); + + it('reports a non-JSON error body as an error', async () => { + const { fetchFn } = recorder( + () => new Response('gateway', { status: 502 }), + ); + const result = await pollDeviceToken(APP_URL, 'dc_1', fetchFn); + expect(result.status).toBe('error'); + }); + }); + + describe('refreshTokens', () => { + it('posts the refresh grant with the refresh token and client id', async () => { + const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); + + await refreshTokens(APP_URL, 'rt_old', fetchFn); + + const call = calls[0]; + expect(call?.url).toBe(`${APP_URL}/api/oauth/token`); + expect(call?.contentType).toBe('application/x-www-form-urlencoded'); + expect(call?.form).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'rt_old', + client_id: CLI_CLIENT_ID, + }); + }); + + it('bounds the request with a timeout below the config lock stale window', async () => { + // A refresh runs while holding the config lock, which is treated as + // abandoned after 15 s. A request allowed to outlive that would have its + // own lock broken out from under it. + // + // The deadline itself is asserted, not merely that some signal is + // attached: a signal built from a 60 s timeout is an AbortSignal too, so + // the shape alone would pass for a value that outlives the lock. + const timeout = jest.spyOn(AbortSignal, 'timeout'); + const { fetchFn, calls } = recorder(() => jsonResponse(tokenBody())); + + await refreshTokens(APP_URL, 'rt_old', fetchFn); + + expect(timeout).toHaveBeenCalledTimes(1); + const [ms] = timeout.mock.calls[0] ?? []; + expect(ms).toBe(10_000); + expect(ms).toBeLessThan(LOCK_STALE_MS); + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); + + timeout.mockRestore(); + }); + + it('returns the rotated token set', async () => { + const { fetchFn } = recorder(() => jsonResponse(tokenBody())); + const result = await refreshTokens(APP_URL, 'rt_old', fetchFn); + + expect(result).not.toBeNull(); + expect(result?.accessToken).toBe('at_new'); + expect(result?.refreshToken).toBe('rt_new'); + }); + + it('reports a null refresh token when the server rotates none', async () => { + const { fetchFn } = recorder(() => + jsonResponse(tokenBody({ refresh_token: undefined })), + ); + const result = await refreshTokens(APP_URL, 'rt_old', fetchFn); + expect(result?.refreshToken).toBeNull(); + }); + + it('returns null on invalid_grant', async () => { + const { fetchFn } = recorder(() => oauthError('invalid_grant')); + await expect( + refreshTokens(APP_URL, 'rt_old', fetchFn), + ).resolves.toBeNull(); + }); + + it('throws on an error that is not invalid_grant', async () => { + // The control for the null result: null must mean "this refresh token is + // dead, re-login", not "any refresh failure". A transient server fault + // must stay distinguishable so it does not delete the stored session. + const { fetchFn } = recorder(() => oauthError('server_error', 500)); + await expect(refreshTokens(APP_URL, 'rt_old', fetchFn)).rejects.toThrow( + /server_error/, + ); + }); + + it('throws when the transport fails', async () => { + const fetchFn: typeof fetch = async () => { + throw new Error('network unreachable'); + }; + await expect(refreshTokens(APP_URL, 'rt_old', fetchFn)).rejects.toThrow( + 'network unreachable', + ); + }); + }); + + describe('revokeRefreshToken', () => { + it('posts the token with a refresh_token hint and the client id', async () => { + const { fetchFn, calls } = recorder( + () => new Response(null, { status: 200 }), + ); + + await revokeRefreshToken(APP_URL, 'rt_old', fetchFn); + + const call = calls[0]; + expect(call?.url).toBe(`${APP_URL}/api/oauth/revoke`); + expect(call?.method).toBe('POST'); + expect(call?.contentType).toBe('application/x-www-form-urlencoded'); + expect(call?.form).toEqual({ + token: 'rt_old', + token_type_hint: 'refresh_token', + client_id: CLI_CLIENT_ID, + }); + }); + + it('bounds the request so a stalled revoke cannot delay the local clear', async () => { + // Logout clears the machine whether or not the server answers, so the + // only thing a stalled revoke can cost is the wait before that clear. + const timeout = jest.spyOn(AbortSignal, 'timeout'); + const { fetchFn, calls } = recorder( + () => new Response(null, { status: 200 }), + ); + + await revokeRefreshToken(APP_URL, 'rt_old', fetchFn); + + // Pinned to the revoke call specifically: one request went out, to the + // revoke endpoint, and one deadline was set for it. + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${APP_URL}/api/oauth/revoke`); + expect(timeout).toHaveBeenCalledTimes(1); + expect(timeout.mock.calls[0]?.[0]).toBe(5_000); + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal); + + timeout.mockRestore(); + }); + + it('resolves despite an error status', async () => { + const { fetchFn } = recorder(() => oauthError('invalid_client', 401)); + await expect( + revokeRefreshToken(APP_URL, 'rt_old', fetchFn), + ).resolves.toBeUndefined(); + }); + + it('resolves despite a transport failure', async () => { + const fetchFn: typeof fetch = async () => { + throw new Error('network unreachable'); + }; + await expect( + revokeRefreshToken(APP_URL, 'rt_old', fetchFn), + ).resolves.toBeUndefined(); + }); + }); + describe('credential transport', () => { + const deviceOk = () => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + expires_in: 600, + interval: 5, + }); + + it('treats a redirect as an error rather than a hop', async () => { + const { fetchFn, calls } = recorder(deviceOk); + await startDeviceAuthorization(APP_URL, fetchFn); + expect(calls[0]?.redirect).toBe('error'); + }); + + it.each([ + ['a device authorization', () => startDeviceAuthorization(HTTP_URL, F())], + ['a token poll', () => pollDeviceToken(HTTP_URL, 'dc_1', F())], + ['a refresh', () => refreshTokens(HTTP_URL, 'rt_1', F())], + ])('refuses %s over plain http off the local machine', async (_l, run) => { + await expect(run()).rejects.toThrow(/plain http/); + }); + + it('sends nothing at all when it refuses', async () => { + const { fetchFn, calls } = recorder(deviceOk); + await expect( + startDeviceAuthorization(HTTP_URL, fetchFn), + ).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + it('swallows the refusal on revocation, which must never block a logout', async () => { + const { fetchFn, calls } = recorder(() => jsonResponse({})); + await expect( + revokeRefreshToken(HTTP_URL, 'rt_1', fetchFn), + ).resolves.toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it('allows plain http to loopback, the documented local flow', async () => { + const { fetchFn, calls } = recorder(deviceOk); + await startDeviceAuthorization('http://localhost:3000', fetchFn); + expect(calls[0]?.url).toBe( + 'http://localhost:3000/api/oauth/device_authorization', + ); + }); + }); +}); diff --git a/packages/cli/src/__tests__/unit/lib/config-file.test.ts b/packages/cli/src/__tests__/unit/lib/config-file.test.ts index 42a311cd4..69dfab102 100644 --- a/packages/cli/src/__tests__/unit/lib/config-file.test.ts +++ b/packages/cli/src/__tests__/unit/lib/config-file.test.ts @@ -1,4 +1,11 @@ -import { readFileSync, rmSync, mkdirSync } from 'fs'; +import { + readFileSync, + readdirSync, + rmSync, + mkdirSync, + statSync, + writeFileSync, +} from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { @@ -186,3 +193,59 @@ describe('WalkerOSConfig telemetry fields', () => { expect(cfg?.token).toBeUndefined(); }); }); + +describe('config-file permissions', () => { + // Its own temp root, and deliberately NOT pre-created: the directory mode is + // one of the guarantees under test, and mkdirSync only applies a mode to a + // directory it creates. + const modeDir = join(tmpdir(), `config-file-mode-test-${Date.now()}`); + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, XDG_CONFIG_HOME: modeDir }; + rmSync(modeDir, { recursive: true, force: true }); + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(modeDir, { recursive: true, force: true }); + }); + + function modeOf(path: string): number { + return statSync(path).mode & 0o777; + } + + it('writes the config readable only by its owner', () => { + writeConfig({ token: 'sk-test-123' }); + expect(modeOf(getConfigPath())).toBe(0o600); + }); + + it('creates the config directory traversable only by its owner', () => { + writeConfig({ token: 'sk-test-123' }); + expect(modeOf(join(modeDir, 'walkeros'))).toBe(0o700); + }); + + it('writes through a temp path of its own, leaving another writer\u2019s alone', () => { + // Only the token refresh takes the config lock, so two ordinary writers + // (a login and a `telemetry enable`, say) can be in here at once. On one + // shared name they would write over each other's temp file and rename it + // twice, and the slower one would fail outright when the faster renamed + // the file out from under its `chmod`. + mkdirSync(join(modeDir, 'walkeros'), { recursive: true }); + const otherWriterTemp = `${getConfigPath()}.tmp`; + writeFileSync(otherWriterTemp, 'another writer'); + + writeConfig({ token: 'sk-test-123' }); + + expect(readFileSync(otherWriterTemp, 'utf-8')).toBe('another writer'); + expect(modeOf(getConfigPath())).toBe(0o600); + expect(readConfig()?.token).toBe('sk-test-123'); + }); + + it('leaves no temp file behind', () => { + writeConfig({ token: 'sk-test-123' }); + writeConfig({ token: 'sk-test-456' }); + + expect(readdirSync(join(modeDir, 'walkeros'))).toEqual(['config.json']); + }); +}); diff --git a/packages/cli/src/__tests__/unit/lib/config-lock.test.ts b/packages/cli/src/__tests__/unit/lib/config-lock.test.ts new file mode 100644 index 000000000..0febdf040 --- /dev/null +++ b/packages/cli/src/__tests__/unit/lib/config-lock.test.ts @@ -0,0 +1,124 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, + utimesSync, + existsSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { withConfigLock, getConfigLockPath } from '../../../lib/config-lock.js'; + +/** Let pending timers and microtasks run for `ms`. */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('withConfigLock', () => { + let dir: string; + const originalXdg = process.env.XDG_CONFIG_HOME; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'walkeros-config-lock-')); + process.env.XDG_CONFIG_HOME = dir; + }); + + afterEach(() => { + if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdg; + rmSync(dir, { recursive: true, force: true }); + }); + + it('serializes two concurrent holders instead of interleaving them', async () => { + const order: string[] = []; + let releaseFirst!: () => void; + const firstHeld = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstEntered!: () => void; + const firstIsInside = new Promise((resolve) => { + firstEntered = resolve; + }); + + const first = withConfigLock(async () => { + order.push('first:enter'); + firstEntered(); + await firstHeld; + order.push('first:exit'); + }); + + await firstIsInside; + + const second = withConfigLock(async () => { + order.push('second:enter'); + order.push('second:exit'); + }); + + // Long enough for several 100 ms acquire retries. Without a real lock the + // second callback would have run to completion by now. + await wait(300); + expect(order).toEqual(['first:enter']); + + releaseFirst(); + await Promise.all([first, second]); + + expect(order).toEqual([ + 'first:enter', + 'first:exit', + 'second:enter', + 'second:exit', + ]); + }); + + it('breaks a lock whose file is older than the stale window', async () => { + const lockPath = getConfigLockPath(); + mkdirSync(dirname(lockPath), { recursive: true }); + writeFileSync(lockPath, ''); + // 20 s of age, past the 15 s stale window. + const past = Date.now() / 1000 - 20; + utimesSync(lockPath, past, past); + + await expect(withConfigLock(async () => 'ran')).resolves.toBe('ran'); + }); + + it('waits for a lock that is not yet stale rather than breaking it', async () => { + // The control for the stale-breaking test above: proves the lock is + // honored when it is fresh, so breaking it there is attributable to its + // age and not to the lock being ignored. + const lockPath = getConfigLockPath(); + mkdirSync(dirname(lockPath), { recursive: true }); + writeFileSync(lockPath, ''); + + let ran = false; + const pending = withConfigLock(async () => { + ran = true; + }); + + await wait(300); + expect(ran).toBe(false); + + rmSync(lockPath); + await pending; + expect(ran).toBe(true); + }); + + it('removes the lock file after the callback resolves', async () => { + await withConfigLock(async () => undefined); + expect(existsSync(getConfigLockPath())).toBe(false); + }); + + it('removes the lock file when the callback throws', async () => { + await expect( + withConfigLock(async () => { + throw new Error('callback failed'); + }), + ).rejects.toThrow('callback failed'); + + expect(existsSync(getConfigLockPath())).toBe(false); + }); + + it('returns the callback result', async () => { + await expect(withConfigLock(async () => 42)).resolves.toBe(42); + }); +}); diff --git a/packages/cli/src/__tests__/unit/lib/secure-url.test.ts b/packages/cli/src/__tests__/unit/lib/secure-url.test.ts new file mode 100644 index 000000000..4899e6072 --- /dev/null +++ b/packages/cli/src/__tests__/unit/lib/secure-url.test.ts @@ -0,0 +1,34 @@ +import { requireSecureUrl } from '../../../lib/secure-url.js'; + +describe('requireSecureUrl', () => { + it.each([ + 'https://app.walkeros.io', + 'https://app.walkeros.io/api/oauth/token', + 'http://localhost:3000', + 'http://localhost', + 'http://127.0.0.1:3000/api/health', + 'http://[::1]:3000', + 'http://LOCALHOST:3000', + ])('passes %s through unchanged', (url) => { + expect(requireSecureUrl(url)).toBe(url); + }); + + it.each([ + 'http://app.walkeros.io', + 'http://internal.lan:3000/api/oauth/token', + 'http://127.0.0.1.evil.test', + 'http://localhost.evil.test', + ])('refuses %s', (url) => { + expect(() => requireSecureUrl(url)).toThrow(url); + }); + + it('names loopback as the exception it allows', () => { + expect(() => requireSecureUrl('http://internal.lan')).toThrow( + /localhost \/ 127\.0\.0\.1 \/ \[::1\]/, + ); + }); + + it('leaves a string that is not a URL to fail at the request', () => { + expect(requireSecureUrl('app.walkeros.io')).toBe('app.walkeros.io'); + }); +}); diff --git a/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts b/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts new file mode 100644 index 000000000..88f47c47c --- /dev/null +++ b/packages/cli/src/__tests__/unit/login/complete-device-login.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { completeDeviceLogin } from '../../../commands/login/index.js'; +import { readConfig, getConfigPath } from '../../../lib/config-file.js'; + +const APP_URL = 'https://app.example.test'; +const DEVICE_CODE = 'dc_resume'; + +/** Long enough for many 1 ms polls, short enough to keep the suite fast. */ +const WINDOW_MS = 200; + +interface RouteState { + /** Queued answers for the token endpoint, consumed in order. */ + tokenAnswers: Array<() => Response>; + whoamiAnswer: () => Response; + tokenCalls: number; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function oauthError(error: string, status = 400): () => Response { + return () => jsonResponse({ error, error_description: error }, status); +} + +function tokenOk(): () => Response { + return () => + jsonResponse({ + access_token: 'at_1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt_1', + }); +} + +function whoamiOk(email = 'user@example.test'): () => Response { + return () => jsonResponse({ userId: 'user_1', email, projectId: null }); +} + +function router(state: RouteState): typeof fetch { + return async (input) => { + const url = String(input); + if (url.endsWith('/api/oauth/token')) { + const answer = + state.tokenAnswers[state.tokenCalls] ?? + state.tokenAnswers[state.tokenAnswers.length - 1]; + state.tokenCalls += 1; + if (!answer) throw new Error('no token answer configured'); + return answer(); + } + if (url.endsWith('/api/auth/whoami')) return state.whoamiAnswer(); + throw new Error(`unexpected request to ${url}`); + }; +} + +function makeState(overrides: Partial = {}): RouteState { + return { + tokenAnswers: [tokenOk()], + whoamiAnswer: whoamiOk(), + tokenCalls: 0, + ...overrides, + }; +} + +function run(state: RouteState) { + return completeDeviceLogin(DEVICE_CODE, { + url: APP_URL, + fetch: router(state), + intervalMs: 1, + timeoutMs: WINDOW_MS, + }); +} + +describe('completeDeviceLogin', () => { + let dir: string; + const originalEnv = process.env; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'walkeros-resume-')); + process.env = { ...originalEnv }; + process.env.XDG_CONFIG_HOME = dir; + delete process.env.WALKEROS_TOKEN; + process.env.WALKEROS_APP_URL = APP_URL; + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(dir, { recursive: true, force: true }); + }); + + it('stores the session and reports ok, carrying no token material back', async () => { + const state = makeState(); + + const result = await run(state); + + // Exact equality, not a status check: the caller must never receive token + // material, because the credential file has exactly one writer. + expect(result).toEqual({ status: 'ok' }); + const stored = readConfig(); + expect(stored?.accessToken).toBe('at_1'); + expect(stored?.refreshToken).toBe('rt_1'); + expect(stored?.email).toBe('user@example.test'); + }); + + it('reports pending and writes nothing when the window closes on an outstanding approval', async () => { + const state = makeState({ + tokenAnswers: [oauthError('authorization_pending')], + }); + + const result = await run(state); + + expect(result).toEqual({ status: 'pending' }); + expect(existsSync(getConfigPath())).toBe(false); + // The window was spent polling, not waiting out one long interval. + expect(state.tokenCalls).toBeGreaterThan(1); + }); + + it('reports slow_down and widens the interval when the server asks for a wider gap', async () => { + // The control for the pending case: same window, same starting interval, + // only the server's answer differs, so both the status and the single poll + // are attributable to `slow_down` rather than to the timing. + const state = makeState({ tokenAnswers: [oauthError('slow_down')] }); + + const result = await run(state); + + expect(result).toEqual({ status: 'slow_down' }); + expect(state.tokenCalls).toBe(1); + expect(existsSync(getConfigPath())).toBe(false); + }); + + it.each([ + ['access_denied', 'denied'], + ['expired_token', 'expired'], + ])('returns %s as status %s and stops polling', async (code, status) => { + const state = makeState({ tokenAnswers: [oauthError(code)] }); + + const result = await run(state); + + expect(result).toEqual({ status }); + expect(state.tokenCalls).toBe(1); + expect(existsSync(getConfigPath())).toBe(false); + }); + + it('returns the server error on an unrecognized failure', async () => { + const state = makeState({ + tokenAnswers: [oauthError('invalid_client', 401)], + }); + + const result = await run(state); + + expect(result.status).toBe('error'); + expect(result).toHaveProperty( + 'error', + expect.stringContaining('invalid_client'), + ); + expect(state.tokenCalls).toBe(1); + expect(existsSync(getConfigPath())).toBe(false); + }); + + it('runs at the interval its caller states, without a floor of its own', async () => { + // The clamp on a server-stated interval belongs at the command's call + // site, not here. Moving it into this helper would make the whole window + // below smaller than one interval and produce zero polls, which is what + // this pins: a caller that states its own pace keeps it. + const state = makeState({ + tokenAnswers: [oauthError('authorization_pending')], + }); + + const result = await run(state); + + expect(result).toEqual({ status: 'pending' }); + expect(state.tokenCalls).toBeGreaterThan(10); + }); + + it('aborts a stalled poll at the deadline instead of waiting out the socket', async () => { + // The double never settles on its own: it settles only when the request's + // OWN signal fires. So the loop running out of attempts cannot rescue this + // case, and without a per-request bound the call would hang until jest's + // timeout rather than return. That is what makes the result attributable + // to the signal. + const seen: Array = []; + const stalling: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + seen.push(init?.signal); + init?.signal?.addEventListener('abort', () => + reject(init.signal?.reason), + ); + }); + + const started = Date.now(); + const result = await completeDeviceLogin(DEVICE_CODE, { + url: APP_URL, + fetch: stalling, + intervalMs: 1, + timeoutMs: 100, + }); + + expect(result).toEqual({ status: 'pending' }); + expect(seen).toHaveLength(1); + expect(seen[0]).toBeInstanceOf(AbortSignal); + expect(Date.now() - started).toBeLessThan(5000); + expect(existsSync(getConfigPath())).toBe(false); + }); + + it('lets a real transport failure through rather than reporting it as pending', async () => { + // The control for the abort case: both end the poll without a response, so + // the abort branch must key on the signal and not on "the request threw". + const failing: typeof fetch = async () => { + throw new Error('network unreachable'); + }; + + await expect( + completeDeviceLogin(DEVICE_CODE, { + url: APP_URL, + fetch: failing, + intervalMs: 1, + timeoutMs: 100, + }), + ).rejects.toThrow('network unreachable'); + }); + + it('keeps the session when the identity lookup fails, without an email', async () => { + const state = makeState({ + whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), + }); + + const result = await run(state); + + expect(result).toEqual({ status: 'ok' }); + expect(readConfig()?.accessToken).toBe('at_1'); + expect(readConfig()?.email).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/__tests__/unit/login/login.test.ts b/packages/cli/src/__tests__/unit/login/login.test.ts index b6e2bb631..a9c408a88 100644 --- a/packages/cli/src/__tests__/unit/login/login.test.ts +++ b/packages/cli/src/__tests__/unit/login/login.test.ts @@ -1,763 +1,495 @@ -import { jest } from '@jest/globals'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import type { - LoginOptions, - DeviceCodeOptions, - PollOptions, -} from '../../../commands/login/index.js'; +import { login } from '../../../commands/login/index.js'; +import { + readConfig, + writeConfig, + getConfigPath, +} from '../../../lib/config-file.js'; -// No-op browser opener for tests -const noopOpen = async () => {}; +const APP_URL = 'https://app.example.test'; -/** Create a fake Response-like object */ -function fakeResponse(body: unknown, init?: { status?: number }) { - const status = init?.status ?? 200; - return { - ok: status >= 200 && status < 300, - status, - json: async () => body, - text: async () => JSON.stringify(body), - } as unknown as Response; -} +/** Mirrors `MIN_SERVER_POLL_INTERVAL_MS` in the command under test. */ +const MIN_SERVER_POLL_INTERVAL_MS = 1000; -function createMockFetch( - handler: (url: string) => Response, -): typeof globalThis.fetch { - return (async (url: string | URL | Request) => { - const urlStr = typeof url === 'string' ? url : url.toString(); - return handler(urlStr); - }) as typeof globalThis.fetch; +const noopOpen = async () => {}; + +interface RouteState { + /** Queued answers for the token endpoint, consumed in order. */ + tokenAnswers: Array<() => Response>; + deviceAnswer: () => Response; + whoamiAnswer: () => Response; + tokenCalls: number; + /** When each token poll went out, so pacing can be asserted. */ + tokenCallTimes: number[]; } -describe('login (device code flow)', () => { - let login: (options?: LoginOptions) => Promise<{ - success: boolean; - email?: string; - configPath?: string; - error?: string; - }>; - let requestDeviceCode: (options?: DeviceCodeOptions) => Promise<{ - deviceCode: string; - userCode: string; - verificationUri: string; - verificationUriComplete?: string; - expiresIn: number; - interval: number; - }>; - let pollForToken: ( - deviceCode: string, - options?: PollOptions, - ) => Promise< - | { - success: true; - status: 'authenticated'; - email: string; - configPath: string; - } - | { success: false; status: 'pending' } - | { success: false; status: 'error'; error: string } - >; - let tmpDir: string; - let origXdg: string | undefined; - - beforeEach(async () => { - // Override the global fake timers from node.setup.mjs - jest.useRealTimers(); - jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Isolate config writes to a temp directory - tmpDir = mkdtempSync(join(tmpdir(), 'walkeros-login-test-')); - origXdg = process.env.XDG_CONFIG_HOME; - process.env.XDG_CONFIG_HOME = tmpDir; - - const loginModule = await import('../../../commands/login/index.js'); - login = loginModule.login; - requestDeviceCode = loginModule.requestDeviceCode; - pollForToken = loginModule.pollForToken; +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, }); +} - afterEach(() => { - jest.restoreAllMocks(); - // Restore env and clean up temp dir - if (origXdg !== undefined) { - process.env.XDG_CONFIG_HOME = origXdg; - } else { - delete process.env.XDG_CONFIG_HOME; - } - rmSync(tmpDir, { recursive: true, force: true }); - }); +function oauthError(error: string, status = 400): () => Response { + return () => jsonResponse({ error, error_description: error }, status); +} - it('polls until approved and returns token', async () => { - let pollCount = 0; - - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 900, - interval: 0, - }); - } - - if (url.includes('/api/auth/device/token')) { - pollCount++; - if (pollCount === 1) { - return fakeResponse( - { error: 'authorization_pending' }, - { status: 400 }, - ); - } - return fakeResponse({ - token: 'sk-walkeros-' + 'b'.repeat(64), - email: 'test@example.com', - userId: 'user_123', - }); - } - - return fakeResponse({ error: 'not found' }, { status: 404 }); +function deviceOk(interval = 0): () => Response { + return () => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + verification_uri_complete: `${APP_URL}/oauth/device?user_code=ABCD-EFGH`, + expires_in: 600, + interval, }); +} - const result = await login({ - openUrl: noopOpen, - fetch: mockFetch, - maxPollAttempts: 10, +function tokenOk(): () => Response { + return () => + jsonResponse({ + access_token: 'at_1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt_1', }); +} - expect(result.success).toBe(true); - expect(result.email).toBe('test@example.com'); - expect(pollCount).toBe(2); - }); - - it('returns expired error when code expires', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 0, - interval: 0, - }); - } +function whoamiOk(email = 'user@example.test'): () => Response { + return () => jsonResponse({ userId: 'user_1', email, projectId: null }); +} - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ error: 'expired_token' }, { status: 400 }); - } +function router(state: RouteState): typeof fetch { + return async (input) => { + const url = String(input); + if (url.endsWith('/api/oauth/device_authorization')) + return state.deviceAnswer(); + if (url.endsWith('/api/oauth/token')) { + const answer = + state.tokenAnswers[state.tokenCalls] ?? + state.tokenAnswers[state.tokenAnswers.length - 1]; + state.tokenCalls += 1; + state.tokenCallTimes.push(Date.now()); + if (!answer) throw new Error('no token answer configured'); + return answer(); + } + if (url.endsWith('/api/auth/whoami')) return state.whoamiAnswer(); + throw new Error(`unexpected request to ${url}`); + }; +} - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); +function makeState(overrides: Partial = {}): RouteState { + return { + deviceAnswer: deviceOk(), + tokenAnswers: [tokenOk()], + whoamiAnswer: whoamiOk(), + tokenCalls: 0, + tokenCallTimes: [], + ...overrides, + }; +} - const result = await login({ - openUrl: noopOpen, - fetch: mockFetch, - maxPollAttempts: 10, - }); - expect(result.success).toBe(false); - expect(result.error).toContain('expired'); +describe('login (device authorization grant)', () => { + let dir: string; + const originalEnv = process.env; + let stderr: jest.SpyInstance; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'walkeros-login-')); + process.env = { ...originalEnv }; + process.env.XDG_CONFIG_HOME = dir; + delete process.env.WALKEROS_TOKEN; + process.env.WALKEROS_APP_URL = APP_URL; + stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); }); - it('handles slow_down by continuing to poll', async () => { - let pollCount = 0; - - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 900, - interval: 0, - }); - } + afterEach(() => { + process.env = originalEnv; + rmSync(dir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); - if (url.includes('/api/auth/device/token')) { - pollCount++; - if (pollCount === 1) { - return fakeResponse({ error: 'slow_down' }, { status: 400 }); - } - return fakeResponse({ - token: 'sk-walkeros-' + 'c'.repeat(64), - email: 'slow@example.com', - userId: 'user_456', - }); - } + function stderrText(): string { + return stderr.mock.calls.map((call) => String(call[0])).join(''); + } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('polls through pending and slow_down, then stores the session', async () => { + const state = makeState({ + tokenAnswers: [ + oauthError('authorization_pending'), + oauthError('slow_down'), + tokenOk(), + ], }); const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), openUrl: noopOpen, - fetch: mockFetch, - maxPollAttempts: 10, }); - expect(result.success).toBe(true); - expect(pollCount).toBe(2); - }, 10_000); - - it('times out when max poll attempts exceeded', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 900, - interval: 0, - }); - } - return fakeResponse({ error: 'authorization_pending' }, { status: 400 }); + expect(result).toEqual({ + success: true, + email: 'user@example.test', + configPath: getConfigPath(), }); - - const result = await login({ - openUrl: noopOpen, - fetch: mockFetch, - maxPollAttempts: 3, - }); - expect(result.success).toBe(false); - expect(result.error).toContain('timed out'); + expect(state.tokenCalls).toBe(3); }); - it('returns error when device code request fails', async () => { - const mockFetch = createMockFetch(() => { - return fakeResponse({ error: 'server error' }, { status: 500 }); + it('paces polling at the clamped minimum when a server states an interval of zero', async () => { + // `WALKEROS_APP_URL` is user-settable, so this number comes from whichever + // host the person is pointed at. Unclamped, a stated zero would let a + // remote value turn this loop into an unthrottled POST flood for the whole + // life of the device code. No `pollIntervalMs` override here: the pacing + // under test is exactly the one a real login would get. + const state = makeState({ + tokenAnswers: [oauthError('authorization_pending'), tokenOk()], }); const result = await login({ + url: APP_URL, + fetch: router(state), openUrl: noopOpen, - fetch: mockFetch, + maxPollAttempts: 2, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to request device code'); - }); - - it('opens verificationUriComplete in browser when available', async () => { - let openedUrl = ''; - const captureOpen = async (url: string) => { - openedUrl = url; - }; - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 900, - interval: 0, - }); - } + expect(result.success).toBe(true); + // Two polls really happened, so the elapsed time is the loop waiting and + // not one slow request: the fetch double answers instantly. + expect(state.tokenCalls).toBe(2); + const [first, second] = state.tokenCallTimes; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + // The gap between them is the clamp doing its work. A hot loop puts these + // microseconds apart. + expect(second! - first!).toBeGreaterThanOrEqual( + MIN_SERVER_POLL_INTERVAL_MS - 50, + ); + }); - return fakeResponse({ - token: 'sk-walkeros-' + 'd'.repeat(64), - email: 'url@example.com', - userId: 'user_789', - }); + it('keeps a server interval that is already above the minimum', async () => { + // The control for the clamp: it is a floor, not a replacement. Flattening + // it to the minimum would pass the zero case above and fail here, so the + // pair is what pins `Math.max` semantics rather than either test alone. + const state = makeState({ + deviceAnswer: deviceOk(2), + tokenAnswers: [oauthError('authorization_pending')], }); + const started = Date.now(); await login({ - openUrl: captureOpen, - fetch: mockFetch, - maxPollAttempts: 10, + url: APP_URL, + fetch: router(state), + openUrl: noopOpen, + maxPollAttempts: 1, }); - expect(openedUrl).toBe('https://app.test/auth/device?user_code=BCDF-GHJK'); + expect(state.tokenCalls).toBe(1); + expect(Date.now() - started).toBeGreaterThanOrEqual(1900); }); - it('displays verificationUriComplete to the user', async () => { - const stderrWrites: string[] = []; - const origWrite = process.stderr.write; - process.stderr.write = ((chunk: string) => { - stderrWrites.push(chunk); - return true; - }) as typeof process.stderr.write; - - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=BCDF-GHJK', - expiresIn: 900, - interval: 0, - }); - } - return fakeResponse({ - token: 'sk-walkeros-' + 'f'.repeat(64), - email: 'test@example.com', - userId: 'user_123', - }); - }); + it('writes the three session fields and no legacy token', async () => { + writeConfig({ token: 'legacy-static-token' }); - try { - await login({ - openUrl: noopOpen, - fetch: mockFetch, - maxPollAttempts: 10, - }); - } finally { - process.stderr.write = origWrite; - } + await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(makeState()), + openUrl: noopOpen, + }); - const output = stderrWrites.join(''); - expect(output).toContain( - 'https://app.test/auth/device?user_code=BCDF-GHJK', + const stored = readConfig(); + expect(stored?.accessToken).toBe('at_1'); + expect(stored?.refreshToken).toBe('rt_1'); + expect(Date.parse(stored?.accessTokenExpiresAt ?? '')).toBeGreaterThan( + Date.now(), ); - }); - - it('falls back to verificationUri when verificationUriComplete is missing', async () => { - let openedUrl = ''; - const captureOpen = async (url: string) => { - openedUrl = url; - }; + expect(stored?.token).toBeUndefined(); - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'a'.repeat(64), - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/auth/device', - expiresIn: 900, - interval: 0, - }); - } + // The on-disk file must not carry the key at all, not merely an undefined + // value, or a later read would resurrect the legacy path. + const raw: unknown = JSON.parse(readFileSync(getConfigPath(), 'utf-8')); + expect(raw).not.toHaveProperty('token'); + }); - return fakeResponse({ - token: 'sk-walkeros-' + 'e'.repeat(64), - email: 'fallback@example.com', - userId: 'user_101', - }); + it('drops a previous refresh token when the server rotates none', async () => { + writeConfig({ + accessToken: 'at_old', + accessTokenExpiresAt: new Date(Date.now() + 1000).toISOString(), + refreshToken: 'rt_previous_session', + }); + const state = makeState({ + tokenAnswers: [ + () => + jsonResponse({ + access_token: 'at_1', + token_type: 'Bearer', + expires_in: 3600, + }), + ], }); await login({ - openUrl: captureOpen, - fetch: mockFetch, - maxPollAttempts: 10, + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, }); - expect(openedUrl).toBe('https://app.test/auth/device'); + expect(readConfig()?.refreshToken).toBeUndefined(); }); - // === requestDeviceCode tests === - - it('requestDeviceCode returns code data on success', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'dc_' + 'a'.repeat(64), - userCode: 'ABCD-EFGH', - verificationUri: 'https://app.test/auth/device', - verificationUriComplete: - 'https://app.test/auth/device?user_code=ABCD-EFGH', - expiresIn: 900, - interval: 5, - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); - - const result = await requestDeviceCode({ - url: 'https://app.test', - fetch: mockFetch, - }); - - expect(result.deviceCode).toBe('dc_' + 'a'.repeat(64)); - expect(result.userCode).toBe('ABCD-EFGH'); - expect(result.verificationUri).toBe('https://app.test/auth/device'); - expect(result.verificationUriComplete).toBe( - 'https://app.test/auth/device?user_code=ABCD-EFGH', - ); - expect(result.expiresIn).toBe(900); - expect(result.interval).toBe(5); - }); + it('drops a previous email when the identity lookup fails', async () => { + // `walkeros feedback` sends the stored address as the reporter's identity, + // so a leftover one attributes this session to the previous account. + writeConfig({ email: 'previous@example.test' }); + const state = makeState({ + whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), + }); - it('requestDeviceCode throws on fetch failure', async () => { - const mockFetch = createMockFetch(() => { - return fakeResponse({ error: 'server error' }, { status: 500 }); + await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, }); - await expect( - requestDeviceCode({ url: 'https://app.test', fetch: mockFetch }), - ).rejects.toThrow('Failed to request device code'); + expect(readConfig()?.email).toBeUndefined(); }); - // === pollForToken tests === - - it('pollForToken returns success when token received', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ - token: 'sk-walkeros-' + 'x'.repeat(64), - email: 'poll@example.com', - userId: 'user_poll', - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('preserves unrelated config that login does not own', async () => { + writeConfig({ + defaultProjectId: 'proj_keep', + installationId: 'install_keep', + telemetryEnabled: true, + anonymousFeedback: false, }); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(makeState()), + openUrl: noopOpen, }); - expect(result.success).toBe(true); - expect(result.status).toBe('authenticated'); - if (result.success) { - expect(result.email).toBe('poll@example.com'); - expect(result.configPath).toBeDefined(); - } + const stored = readConfig(); + expect(stored?.defaultProjectId).toBe('proj_keep'); + expect(stored?.installationId).toBe('install_keep'); + expect(stored?.telemetryEnabled).toBe(true); + expect(stored?.anonymousFeedback).toBe(false); + expect(stored?.accessToken).toBe('at_1'); }); - it('pollForToken returns pending on timeout', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse( - { error: 'authorization_pending' }, - { status: 400 }, - ); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); + it('reports a timeout when the device code expires', async () => { + const state = makeState({ tokenAnswers: [oauthError('expired_token')] }); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 100, - intervalMs: 30, + await expect( + login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, + }), + ).resolves.toEqual({ + success: false, + error: 'Authorization timed out. Please try again.', }); - - expect(result.success).toBe(false); - expect(result.status).toBe('pending'); + expect(existsSync(getConfigPath())).toBe(false); }); - it('pollForToken handles slow_down by increasing interval', async () => { - let pollCount = 0; + it('reports a denial distinctly from a timeout', async () => { + // The control for the expiry test: two different terminal outcomes must + // not collapse into the same message. + const state = makeState({ tokenAnswers: [oauthError('access_denied')] }); - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - pollCount++; - if (pollCount === 1) { - return fakeResponse({ error: 'slow_down' }, { status: 400 }); - } - return fakeResponse({ - token: 'sk-walkeros-' + 'y'.repeat(64), - email: 'slow@example.com', - userId: 'user_slow', - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); - - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 30000, - intervalMs: 10, + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, }); - expect(result.success).toBe(true); - expect(result.status).toBe('authenticated'); - expect(pollCount).toBe(2); - }, 10_000); + expect(result.success).toBe(false); + expect(result.error).toBe('Authorization was denied.'); + }); - it('pollForToken returns error on denied', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ error: 'access_denied' }, { status: 400 }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('stops polling and reports the error on an unrecognized failure', async () => { + const state = makeState({ + tokenAnswers: [oauthError('invalid_client', 401)], }); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, }); expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toBe('access_denied'); - } + expect(result.error).toContain('invalid_client'); + expect(state.tokenCalls).toBe(1); }); - it('pollForToken returns error on expired token', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ error: 'expired_token' }, { status: 400 }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('gives up after maxPollAttempts', async () => { + const state = makeState({ + tokenAnswers: [oauthError('authorization_pending')], }); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, + maxPollAttempts: 3, }); - expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toBe('expired_token'); - } + expect(result).toEqual({ + success: false, + error: 'Authorization timed out. Please try again.', + }); + expect(state.tokenCalls).toBe(3); }); - // === Bounded-fetch + malformed-JSON safety === - - it('pollForToken passes an AbortSignal to fetch', async () => { - const receivedInits: RequestInit[] = []; - const mockFetch: typeof globalThis.fetch = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - const urlStr = typeof url === 'string' ? url : url.toString(); - if (urlStr.includes('/api/auth/device/token')) { - receivedInits.push(init ?? {}); - return fakeResponse({ - token: 'sk-walkeros-' + 'a'.repeat(64), - email: 'signal@example.com', - userId: 'user_signal', - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }) as typeof globalThis.fetch; - - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + it('returns an error when the device authorization request fails', async () => { + const state = makeState({ + deviceAnswer: oauthError('invalid_client', 401), }); - expect(result.success).toBe(true); - expect(receivedInits.length).toBeGreaterThan(0); - const lastInit = receivedInits[receivedInits.length - 1]; - expect(lastInit.signal).toBeDefined(); - expect(typeof (lastInit.signal as AbortSignal).aborted).toBe('boolean'); + await expect( + login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, + }), + ).resolves.toEqual({ + success: false, + error: 'Failed to request device code', + }); }); - it('pollForToken aborts in-flight fetch when deadline passes', async () => { - // The fetch hangs forever unless aborted. If the bounded timeout works, - // the pollForToken call resolves with pending (timeout) rather than hanging. - let aborted = false; - const mockFetch: typeof globalThis.fetch = (async ( - url: string | URL | Request, - init?: RequestInit, - ) => { - const urlStr = typeof url === 'string' ? url : url.toString(); - if (urlStr.includes('/api/auth/device/token')) { - const signal = init?.signal; - return await new Promise((_resolve, reject) => { - if (signal) { - signal.addEventListener('abort', () => { - aborted = true; - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; - reject(abortError); - }); - } - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }) as typeof globalThis.fetch; - - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 150, - intervalMs: 10, - }); - - expect(aborted).toBe(true); - expect(result.success).toBe(false); - // AbortError at the deadline is a timeout, not a real error - expect(['pending', 'error']).toContain(result.status); - }, 5000); - - it('pollForToken returns error shape on non-JSON token response', async () => { - const htmlResponse = { - ok: true, - status: 200, - json: async () => { - throw new SyntaxError('Unexpected token < in JSON at position 0'); - }, - text: async () => 'server error', - } as unknown as Response; - - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return htmlResponse; - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); + it('shows the user code and opens the complete verification URL', async () => { + const opened: string[] = []; - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(makeState()), + openUrl: async (url) => { + opened.push(url); + }, }); - expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toMatch(/malformed/i); - } + expect(opened).toEqual([`${APP_URL}/oauth/device?user_code=ABCD-EFGH`]); + const text = stderrText(); + expect(text).toContain('ABCD-EFGH'); + expect(text).toContain(`${APP_URL}/oauth/device?user_code=ABCD-EFGH`); }); - it('pollForToken returns error shape when ok response is missing token field', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - // ok: true but no token/email at all - return fakeResponse({ something: 'else' }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('falls back to the plain verification URL when no complete form is given', async () => { + const state = makeState({ + deviceAnswer: () => + jsonResponse({ + device_code: 'dc_1', + user_code: 'ABCD-EFGH', + verification_uri: `${APP_URL}/oauth/device`, + expires_in: 600, + interval: 0, + }), }); + const opened: string[] = []; - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 200, - intervalMs: 10, + await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: async (url) => { + opened.push(url); + }, }); - expect(result.success).toBe(false); - // Without a token/email and without an error field, this is just pending until timeout - // but should NOT have thrown or written config. - expect(['pending', 'error']).toContain(result.status); + expect(opened).toEqual([`${APP_URL}/oauth/device`]); }); - it('pollForToken returns error shape when token field is wrong type', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ - token: 12345, // not a string - email: 'x@example.com', - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('still succeeds when the identity lookup fails, without an email', async () => { + const state = makeState({ + whoamiAnswer: () => jsonResponse({ error: 'nope' }, 500), }); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, }); - expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toMatch(/malformed/i); - } + expect(result.success).toBe(true); + expect(result.email).toBeUndefined(); + expect(readConfig()?.accessToken).toBe('at_1'); }); - // === Zod schema validation (TokenResponseSchema + DeviceCodeResponseSchema) === - - it('requestDeviceCode throws when deviceCode field is missing (Zod)', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - userCode: 'X', - verificationUri: 'https://app.test/device', - expiresIn: 900, - interval: 5, - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + it('keeps a browser that will not open from failing the login', async () => { + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: router(makeState()), + openUrl: async () => { + throw new Error('no display'); + }, }); - await expect( - requestDeviceCode({ url: 'https://app.test', fetch: mockFetch }), - ).rejects.toThrow(/malformed|invalid/i); + expect(result.success).toBe(true); + expect(stderrText()).toContain('Could not open browser'); }); + it('bounds the identity lookup, which runs after the session is stored', async () => { + const route = router(makeState()); + let whoamiSignal: AbortSignal | null | undefined; + const fetchFn: typeof fetch = async (input, init) => { + if (String(input).endsWith('/api/auth/whoami')) + whoamiSignal = init?.signal; + return route(input, init); + }; - it('requestDeviceCode throws when expiresIn is a string instead of number (Zod)', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/code')) { - return fakeResponse({ - deviceCode: 'dc_abc', - userCode: 'BCDF-GHJK', - verificationUri: 'https://app.test/device', - expiresIn: '900', // wrong type - interval: 5, - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); + const result = await login({ + url: APP_URL, + pollIntervalMs: 1, + fetch: fetchFn, + openUrl: noopOpen, }); - await expect( - requestDeviceCode({ url: 'https://app.test', fetch: mockFetch }), - ).rejects.toThrow(/malformed|invalid/i); + expect(result.success).toBe(true); + expect(whoamiSignal).toBeInstanceOf(AbortSignal); }); - it('pollForToken rejects numeric token via Zod (schema negative case)', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ - token: 4242, // number not string - email: 'n@example.com', - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); + it('refuses an app URL that would carry the session over plain http', async () => { + const state = makeState(); - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, - }); + await expect( + login({ + url: 'http://app.example.test', + pollIntervalMs: 1, + fetch: router(state), + openUrl: noopOpen, + }), + ).rejects.toThrow(/plain http/); - expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toMatch(/malformed/i); - } + expect(state.tokenCalls).toBe(0); + expect(readConfig()).toBeNull(); }); - it('pollForToken rejects response missing email via Zod', async () => { - const mockFetch = createMockFetch((url) => { - if (url.includes('/api/auth/device/token')) { - return fakeResponse({ - token: 'sk-walkeros-' + 'z'.repeat(64), - // email missing - }); - } - return fakeResponse({ error: 'not found' }, { status: 404 }); - }); - - const result = await pollForToken('dc_test_code', { - url: 'https://app.test', - fetch: mockFetch, - timeoutMs: 10000, - intervalMs: 10, + it('accepts a loopback app URL over plain http', async () => { + const result = await login({ + url: 'http://127.0.0.1:3000', + pollIntervalMs: 1, + fetch: router(makeState()), + openUrl: noopOpen, }); - expect(result.success).toBe(false); - expect(result.status).toBe('error'); - if (!result.success && result.status === 'error') { - expect(result.error).toMatch(/malformed|email/i); - } + expect(result.success).toBe(true); + expect(readConfig()?.appUrl).toBe('http://127.0.0.1:3000'); }); }); diff --git a/packages/cli/src/commands/frames/index.ts b/packages/cli/src/commands/frames/index.ts new file mode 100644 index 000000000..8ca6b3c93 --- /dev/null +++ b/packages/cli/src/commands/frames/index.ts @@ -0,0 +1,61 @@ +import { requireProjectId } from '../../core/auth.js'; +import { apiFetch } from '../../core/http.js'; +import { throwApiResponseError } from '../../core/api-error.js'; +import type { components } from '../../types/api.gen.js'; + +type FrameResponse = components['schemas']['Frame']; +type FrameListResponse = components['schemas']['FrameListResponse']; +type FrameLeanListResponse = components['schemas']['FrameLeanListResponse']; + +async function readJson(response: Response, fallback: string): Promise { + if (!response.ok) { + const body: unknown = await response.json().catch(() => ({})); + throwApiResponseError(response, body, fallback); + } + return response.json(); +} + +export interface ListFramesOptions { + projectId?: string; +} + +/** Every live frame of the project, without marks. Requires the frames feature. */ +export async function listFrames( + options: ListFramesOptions = {}, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const response = await apiFetch(`/api/projects/${pid}/frames`); + return readJson(response, 'Failed to list frames'); +} + +export interface ListPageFramesOptions { + projectId?: string; + pageKey: string; +} + +/** The frames of one page at any depth, with their marks. */ +export async function listPageFrames( + options: ListPageFramesOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const params = new URLSearchParams({ pageKey: options.pageKey }); + const response = await apiFetch( + `/api/projects/${pid}/frames?${params.toString()}`, + ); + return readJson(response, 'Failed to list page frames'); +} + +export interface GetFrameOptions { + projectId?: string; + frameId: string; +} + +export async function getFrame( + options: GetFrameOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const response = await apiFetch( + `/api/projects/${pid}/frames/${encodeURIComponent(options.frameId)}`, + ); + return readJson(response, 'Failed to read frame'); +} diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts new file mode 100644 index 000000000..c6e81b632 --- /dev/null +++ b/packages/cli/src/commands/hub/index.ts @@ -0,0 +1,253 @@ +import { requireProjectId } from '../../core/auth.js'; +import { apiFetch } from '../../core/http.js'; +import { throwApiResponseError } from '../../core/api-error.js'; +import type { components } from '../../types/api.gen.js'; + +type VersionAnnotation = components['schemas']['VersionAnnotation']; +type StepHistoryResponse = components['schemas']['StepHistoryResponse']; +type ListHubThreadsResponse = components['schemas']['ListHubThreadsResponse']; +type HubThreadResponse = components['schemas']['HubThreadResponse']; +type ListKnowledgeResponse = components['schemas']['ListKnowledgeResponse']; + +// === Release wire shapes, aliased onto the generated components === + +/** The rationale summary a release index row carries when asked for one. */ +export type ReleaseRationaleSummary = + components['schemas']['ReleaseRationaleSummary']; + +/** The release index. Each row carries `rationale` when one was asked for. */ +export type ReleaseIndexResponse = + components['schemas']['ListFlowReleasesResponse']; + +/** The diff a release carries against its spine predecessor. */ +export type ReleaseDiffResponse = components['schemas']['ReleaseDiff']; + +/** One release in full: rationale plus the diff the server computed. */ +export type ReleaseDetailResponse = + components['schemas']['ReleaseDetailResponse']; + +// === Programmatic API === + +async function readJson(response: Response, fallback: string): Promise { + if (!response.ok) { + const body: unknown = await response.json().catch(() => ({})); + throwApiResponseError(response, body, fallback); + } + return response.json(); +} + +export interface ListReleasesOptions { + projectId?: string; + flowId: string; + limit?: number; + offset?: number; +} + +/** The release index WITH its rationale summary. Requires the hub feature. */ +export async function listReleases( + options: ListReleasesOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const params = new URLSearchParams({ rationale: 'true' }); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + if (options.offset !== undefined) + params.set('offset', String(options.offset)); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/releases?${params.toString()}`, + ); + return readJson(response, 'Failed to list releases'); +} + +/** How a release is addressed: by spine id, or by spine number. */ +export type ReleaseRef = { versionId: string } | { versionNumber: number }; + +export interface GetReleaseOptions { + projectId?: string; + flowId: string; + ref: ReleaseRef; +} + +/** + * One release in full: rationale plus the diff the SERVER computed against the + * spine predecessor. The path segment is the id or the number; the app decides + * which it was. + */ +export async function getRelease( + options: GetReleaseOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const segment = + 'versionId' in options.ref + ? options.ref.versionId + : String(options.ref.versionNumber); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/releases/${encodeURIComponent(segment)}`, + ); + return readJson(response, 'Failed to read release'); +} + +export interface ListStepHistoryOptions { + projectId?: string; + flowId: string; + step: string; + flow?: string; + limit?: number; +} + +export async function listStepHistory( + options: ListStepHistoryOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const params = new URLSearchParams({ step: options.step }); + if (options.flow !== undefined) params.set('flow', options.flow); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/releases/step-history?${params.toString()}`, + ); + return readJson(response, 'Failed to read step history'); +} + +export interface SetReleaseRationaleOptions { + projectId?: string; + flowId: string; + versionId: string; + /** + * The rationale to store. `null` CLEARS the one already there: `humanText` + * is the only field a client may write, and null is how the route says + * "remove it". Without it there would be no way back from a rationale + * written by mistake. + */ + text: string | null; +} + +export async function setReleaseRationale( + options: SetReleaseRationaleOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/releases/annotations`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + versionId: options.versionId, + humanText: options.text, + }), + }, + ); + return readJson(response, 'Failed to write release rationale'); +} + +export type ThreadAnchorType = + | 'step' + | 'entity_action' + | 'release' + | 'contract' + | 'tag'; +export type ThreadStatus = 'open' | 'resolved'; + +export interface ListThreadsOptions { + projectId?: string; + flowId: string; + anchorType?: ThreadAnchorType; + anchorKey?: string; + status?: ThreadStatus; + includeMessages: boolean; + limit?: number; +} + +export async function listThreads( + options: ListThreadsOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const params = new URLSearchParams(); + if (options.anchorType !== undefined) + params.set('anchorType', options.anchorType); + if (options.anchorKey !== undefined) + params.set('anchorKey', options.anchorKey); + if (options.status !== undefined) params.set('status', options.status); + params.set('includeMessages', options.includeMessages ? 'true' : 'false'); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/threads?${params.toString()}`, + ); + return readJson(response, 'Failed to list threads'); +} + +export interface CreateThreadOptions { + projectId?: string; + flowId: string; + anchorType: ThreadAnchorType; + anchorKey: string; + anchorLabel?: string; + text: string; +} + +export async function createThread( + options: CreateThreadOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/threads`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + anchorType: options.anchorType, + anchorKey: options.anchorKey, + ...(options.anchorLabel !== undefined + ? { anchorLabel: options.anchorLabel } + : {}), + text: options.text, + }), + }, + ); + return readJson(response, 'Failed to open thread'); +} + +export interface AddThreadMessageOptions { + projectId?: string; + flowId: string; + threadId: string; + text: string; +} + +export async function addThreadMessage( + options: AddThreadMessageOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const response = await apiFetch( + `/api/projects/${pid}/flows/${options.flowId}/threads/${encodeURIComponent(options.threadId)}/messages`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: options.text }), + }, + ); + return readJson(response, 'Failed to add message'); +} + +export interface ListKnowledgeOptions { + projectId?: string; + pageKey?: string; + frameId?: string; + markId?: string; + includeMessages: boolean; + limit?: number; +} + +export async function listKnowledge( + options: ListKnowledgeOptions, +): Promise { + const pid = options.projectId ?? requireProjectId(); + const params = new URLSearchParams(); + if (options.pageKey !== undefined) params.set('pageKey', options.pageKey); + if (options.frameId !== undefined) params.set('frameId', options.frameId); + if (options.markId !== undefined) params.set('markId', options.markId); + params.set('includeMessages', options.includeMessages ? 'true' : 'false'); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + const response = await apiFetch( + `/api/projects/${pid}/knowledge?${params.toString()}`, + ); + return readJson(response, 'Failed to read knowledge'); +} diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 9a466fdbb..73016906d 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,38 +1,68 @@ -import { hostname } from 'os'; import { z } from 'zod'; import { createCLILogger } from '../../core/cli-logger.js'; import { + startDeviceAuthorization, + pollDeviceToken, + type TokenSet, +} from '../../core/oauth-client.js'; +import { + readConfig, writeConfig, resolveAppUrl, getConfigPath, } from '../../lib/config-file.js'; +import { requireSecureUrl } from '../../lib/secure-url.js'; import type { GlobalOptions } from '../../types/global.js'; /** - * Zod schema for the device-code response from `POST /api/auth/device/code`. - * Validates the trust boundary between the auth server and the CLI so a - * malformed response cannot propagate `undefined` into the browser-opener - * or subsequent token polling. + * `walkeros auth login` on the RFC 8628 device authorization grant. + * + * The CLI never sees a password and never runs a local callback server: it + * shows a code, the person approves it in a browser they already trust, and + * the CLI polls until the approval lands. + */ + +/** Grace added to the server's stated lifetime before we stop polling. */ +const POLL_TIMEOUT_BUFFER_MS = 5000; + +/** Interval to use when the caller states none. */ +const DEFAULT_POLL_INTERVAL_MS = 5000; + +/** + * Floor on an interval that came from a SERVER. + * + * RFC 8628 lets a server state any minimum gap between polls, including none, + * but `WALKEROS_APP_URL` is user-settable, so that number arrives from + * whichever host the person is pointed at. A stated zero would otherwise set + * this loop's pace to "as fast as the network allows" for the whole life of + * the device code. A remote value may slow the loop down, never off its leash. + */ +const MIN_SERVER_POLL_INTERVAL_MS = 1000; + +/** + * Window to poll for when the caller states none. Sized to the lifetime a + * device code is typically issued with, so a resume that was given no window + * cannot outlive the code it is polling for. */ -const DeviceCodeResponseSchema = z.object({ - deviceCode: z.string().min(1), - userCode: z.string().min(1), - verificationUri: z.string().min(1), - verificationUriComplete: z.string().optional(), - // Server protocol allows 0 for both (e.g. fast retry / already expired). - expiresIn: z.number().int().nonnegative(), - interval: z.number().int().nonnegative(), -}); +const DEFAULT_POLL_TIMEOUT_MS = 900_000; + +/** RFC 8628 section 3.5: each `slow_down` adds five seconds. */ +const SLOW_DOWN_STEP_MS = 5000; /** - * Zod schema for the token response from `POST /api/auth/device/token` on 2xx. - * Validates that the token and email are both present and are strings before - * writing them to the on-disk config. + * Ceiling on the identity lookup. It runs AFTER the session is on disk, so a + * server that accepts the connection and then says nothing would otherwise + * hold `walkeros auth login` open long past its last useful work. */ -const TokenResponseSchema = z.object({ - token: z.string().min(1), - email: z.string().min(1), -}); +const WHOAMI_TIMEOUT_MS = 10_000; + +const TIMED_OUT = 'Authorization timed out. Please try again.'; + +/** + * The identity endpoint's response. Validated because a malformed body must + * not put a non-string into the config where the email belongs. + */ +const WhoamiSchema = z.object({ email: z.string().min(1) }); export interface LoginCommandOptions extends GlobalOptions { url?: string; @@ -54,50 +84,52 @@ export interface LoginOptions { fetch?: typeof globalThis.fetch; /** Max poll attempts before giving up (for testing) */ maxPollAttempts?: number; + /** Poll interval, replacing the server's stated one (for testing) */ + pollIntervalMs?: number; } -export interface DeviceCodeResult { - deviceCode: string; - userCode: string; - verificationUri: string; - verificationUriComplete?: string; - expiresIn: number; - interval: number; -} - -export interface DeviceCodeOptions { - url?: string; - fetch?: typeof globalThis.fetch; -} - -export interface PollOptions { +/** + * The outcome of finishing a device authorization. + * + * It carries no token material. `completeDeviceLogin` stores the session + * itself, so the credential file keeps exactly one writer and a caller can + * neither persist nor leak what came back. + * + * `pending` and `slow_down` both mean the window closed with the approval + * still outstanding: the device code is untouched, so the same code can be + * handed back in. `slow_down` is that same situation with the server asking + * for a wider gap before the next attempt. + */ +export type DeviceLoginResult = + | { status: 'ok' } + | { status: 'pending' } + | { status: 'slow_down' } + | { status: 'denied' } + | { status: 'expired' } + | { status: 'error'; error: string }; + +export interface CompleteDeviceLoginOptions { + /** App to poll. Defaults to the resolved app URL. */ url?: string; - fetch?: typeof globalThis.fetch; - /** Timeout in milliseconds. Defaults to 60000 (60s). */ + /** Stop polling after this long. */ timeoutMs?: number; - /** Poll interval in milliseconds. Defaults to 5000. */ + /** Wait between polls, before any `slow_down` widens it. */ intervalMs?: number; + /** Override fetch for testing */ + fetch?: typeof globalThis.fetch; + /** Max poll attempts before giving up (for testing) */ + maxPollAttempts?: number; } -export type PollResult = - | { - success: true; - status: 'authenticated'; - email: string; - configPath: string; - } - | { success: false; status: 'pending' } - | { success: false; status: 'error'; error: string }; - -const POLL_TIMEOUT_BUFFER_MS = 5000; -const DEFAULT_POLL_TIMEOUT_MS = 60000; -const DEFAULT_POLL_INTERVAL_MS = 5000; - async function openInBrowser(url: string): Promise { const { default: open } = await import('open'); await open(url); } +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export async function loginCommand( options: LoginCommandOptions, ): Promise { @@ -109,8 +141,11 @@ export async function loginCommand( if (options.json) { logger.json(result); } else if (result.success) { - logger.info(`Logged in as ${result.email}`); - logger.info(`Token stored in ${result.configPath}`); + if (result.email) logger.info(`Logged in as ${result.email}`); + else logger.info('Logged in.'); + logger.info(`Session stored in ${result.configPath}`); + } else if (result.error) { + logger.error(result.error); } process.exit(result.success ? 0 : 1); @@ -128,209 +163,134 @@ export async function loginCommand( } /** - * Request a device code from the auth server. - * First step of the device code flow — returns data needed to show - * the user a code and URL, then poll for the token. + * Ask the API who the freshly issued token belongs to. + * + * Non-fatal: the session is already stored and usable, and an address is only + * there so the CLI can name the account it logged into. */ -export async function requestDeviceCode( - options: DeviceCodeOptions = {}, -): Promise { - const appUrl = options.url || resolveAppUrl(); - const f = options.fetch ?? globalThis.fetch; - - const response = await f(`${appUrl}/api/auth/device/code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - - if (!response.ok) { - throw new Error('Failed to request device code'); - } - - let raw: unknown; +async function fetchEmail( + appUrl: string, + accessToken: string, + fetchFn: typeof globalThis.fetch, +): Promise { try { - raw = await response.json(); + const response = await fetchFn(`${appUrl}/api/auth/whoami`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(WHOAMI_TIMEOUT_MS), + }); + if (!response.ok) return undefined; + const parsed = WhoamiSchema.safeParse(await response.json()); + return parsed.success ? parsed.data.email : undefined; } catch { - throw new Error('Malformed device code response'); + return undefined; } +} - const parsed = DeviceCodeResponseSchema.safeParse(raw); - if (!parsed.success) { - throw new Error('Malformed device code response'); - } +/** Store the session a device authorization yielded, and name its account. */ +async function persistSession( + appUrl: string, + tokens: TokenSet, + fetchFn: typeof globalThis.fetch, +): Promise { + // Every credential key is written explicitly, including the ones that may + // be absent: `writeConfig` merges, so a key left out would keep the + // PREVIOUS session's value and quietly outlive the login that replaced it. + writeConfig({ + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshToken: tokens.refreshToken ?? undefined, + appUrl, + // Drop the static token this session replaces, so the deprecated path + // cannot outlive the login that retired it. + token: undefined, + }); - return { - deviceCode: parsed.data.deviceCode, - userCode: parsed.data.userCode, - verificationUri: parsed.data.verificationUri, - verificationUriComplete: parsed.data.verificationUriComplete, - expiresIn: parsed.data.expiresIn, - interval: parsed.data.interval, - }; + // Unconditional: a stale address is worse than none, since `walkeros + // feedback` sends it as the reporter's identity. + const email = await fetchEmail(appUrl, tokens.accessToken, fetchFn); + writeConfig({ email }); } /** - * Poll the auth server until the device code is authorized, times out, or fails. - * Second step of the device code flow. - * - * On success: writes config and returns authenticated result. - * On timeout: returns pending (NOT an error — caller can retry). - * On real error (denied, expired): returns error result. + * Poll a device authorization to its end and store the session it yields. * - * In-flight fetch requests are bounded by the remaining time to the deadline - * via AbortController, so a hanging fetch cannot exceed the configured timeout. - * Malformed JSON responses return an error result instead of throwing. + * Split out from `login` so a caller holding only a device code can finish an + * authorization that is already under way. `login` cannot serve that: it + * starts a fresh authorization on every call, which would strand the code the + * person is looking at. */ -export async function pollForToken( +export async function completeDeviceLogin( deviceCode: string, - options: PollOptions = {}, -): Promise { - const appUrl = options.url || resolveAppUrl(); - const f = options.fetch ?? globalThis.fetch; - const timeoutMs = options.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; - let intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; - - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, intervalMs)); - - // Check if we've exceeded the deadline after sleeping - if (Date.now() >= deadline) break; - - const remaining = Math.max(1, deadline - Date.now()); - const controller = new AbortController(); - const timeoutHandle = setTimeout(() => controller.abort(), remaining); - - let tokenResponse: Response; - try { - tokenResponse = await f(`${appUrl}/api/auth/device/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceCode, hostname: hostname() }), - signal: controller.signal, - }); - } catch (err) { - clearTimeout(timeoutHandle); - if (err instanceof Error && err.name === 'AbortError') { - // Aborted by our deadline — fall through to the pending return below - break; - } - throw err; - } finally { - clearTimeout(timeoutHandle); - } + options: CompleteDeviceLoginOptions = {}, +): Promise { + const fetchFn = options.fetch ?? globalThis.fetch; + const appUrl = requireSecureUrl(options.url || resolveAppUrl()); + const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS); - const data = await safeJsonParse(tokenResponse); - if (data === MALFORMED) { - return { - success: false, - status: 'error', - error: 'Server returned malformed response', - }; - } - - if (tokenResponse.ok) { - // ok=true but no token and no error field — treat as pending and continue. - // Only try to validate as a token response if a `token` key is present. - if (data.token === undefined) { - continue; - } - const tokenParsed = TokenResponseSchema.safeParse(data); - if (!tokenParsed.success) { - return { - success: false, - status: 'error', - error: 'Server returned malformed token response', - }; - } - const { token, email } = tokenParsed.data; - writeConfig({ token, email, appUrl }); - const configPath = getConfigPath(); - return { - success: true, - status: 'authenticated', - email, - configPath, - }; + let intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + let attempts = 0; + let waiting: 'pending' | 'slow_down' = 'pending'; + + // Waiting out the interval is part of an attempt, so there is only room for + // another poll while a whole interval still fits inside the window. + while (Date.now() + intervalMs <= deadline) { + if ( + options.maxPollAttempts !== undefined && + attempts >= options.maxPollAttempts + ) + break; + attempts += 1; + + await delay(intervalMs); + + // The window bounds the request too, not just how many are started: a + // stalled response would otherwise run past the deadline on the HTTP + // client's own clock. + const poll = await pollDeviceToken( + appUrl, + deviceCode, + fetchFn, + AbortSignal.timeout(Math.max(1, deadline - Date.now())), + ); + + if (poll.status === 'pending') { + waiting = 'pending'; + continue; } - - if (data.error === 'authorization_pending') continue; - if (data.error === 'slow_down') { - intervalMs += 5000; + if (poll.status === 'slow_down') { + waiting = 'slow_down'; + intervalMs += SLOW_DOWN_STEP_MS; continue; } + if (poll.status !== 'ok') return poll; - // Any other error: expired, denied, etc. - const errField: unknown = data.error; - let errorMsg: string; - if (typeof errField === 'string') { - errorMsg = errField; - } else if ( - errField && - typeof errField === 'object' && - 'message' in errField && - typeof (errField as { message: unknown }).message === 'string' - ) { - errorMsg = (errField as { message: string }).message; - } else { - errorMsg = 'Authorization failed'; - } - return { success: false, status: 'error', error: errorMsg }; + await persistSession(appUrl, poll.tokens, fetchFn); + return { status: 'ok' }; } - return { success: false, status: 'pending' }; -} - -const MALFORMED = Symbol('malformed-json'); - -async function safeJsonParse( - response: Response, -): Promise | typeof MALFORMED> { - try { - const parsed: unknown = await response.json(); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - return MALFORMED; - } catch { - return MALFORMED; - } + return { status: waiting }; } export async function login(options: LoginOptions = {}): Promise { - const fetchOption = options.fetch ?? globalThis.fetch; - const urlOption = options.url; + const fetchFn = options.fetch ?? globalThis.fetch; + const appUrl = requireSecureUrl(options.url || resolveAppUrl()); - // 1. Request device code - let codeResult: DeviceCodeResult; + let authorization; try { - codeResult = await requestDeviceCode({ - url: urlOption, - fetch: fetchOption, - }); + authorization = await startDeviceAuthorization(appUrl, fetchFn); } catch { return { success: false, error: 'Failed to request device code' }; } - const { - userCode, - verificationUri, - verificationUriComplete, - expiresIn, - interval, - deviceCode, - } = codeResult; + const target = authorization.verificationUriComplete; - // 2. Display code and open browser - const prompt = (msg: string) => process.stderr.write(msg + '\n'); - prompt(`\n! Your one-time code: ${userCode}`); - prompt(` Authorize here: ${verificationUriComplete || verificationUri}\n`); + const prompt = (message: string) => process.stderr.write(message + '\n'); + prompt(`\n! Your one-time code: ${authorization.userCode}`); + prompt(` Authorize here: ${target}\n`); const opener = options.openUrl ?? openInBrowser; try { - await opener(verificationUriComplete || verificationUri); + await opener(target); prompt(' Opening browser...'); } catch { prompt(' Could not open browser. Visit the URL manually.'); @@ -338,110 +298,38 @@ export async function login(options: LoginOptions = {}): Promise { prompt(' Waiting for authorization... (press Ctrl+C to cancel)\n'); - // 3. Poll for token - // Use expiresIn-based timeout (original behavior) with maxPollAttempts support - const timeoutMs = expiresIn * 1000 + POLL_TIMEOUT_BUFFER_MS; - const intervalMs = (interval ?? 5) * 1000; - - if (options.maxPollAttempts !== undefined) { - // Legacy path: use attempt-based polling for backward compat with tests - const appUrl = urlOption || resolveAppUrl(); - const f = fetchOption; - let pollInterval = intervalMs; - let attempts = 0; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline && attempts < options.maxPollAttempts) { - attempts++; - await new Promise((r) => setTimeout(r, pollInterval)); - - const remaining = Math.max(1, deadline - Date.now()); - const controller = new AbortController(); - const timeoutHandle = setTimeout(() => controller.abort(), remaining); - - let tokenResponse: Response; - try { - tokenResponse = await f(`${appUrl}/api/auth/device/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceCode, hostname: hostname() }), - signal: controller.signal, - }); - } catch (err) { - clearTimeout(timeoutHandle); - if (err instanceof Error && err.name === 'AbortError') { - break; - } - throw err; - } finally { - clearTimeout(timeoutHandle); - } - - const data = await safeJsonParse(tokenResponse); - if (data === MALFORMED) { - return { - success: false, - error: 'Server returned malformed response', - }; - } - - if (tokenResponse.ok) { - if (data.token === undefined) { - continue; - } - const tokenParsed = TokenResponseSchema.safeParse(data); - if (!tokenParsed.success) { - return { - success: false, - error: 'Server returned malformed token response', - }; - } - const { token, email } = tokenParsed.data; - writeConfig({ token, email, appUrl }); - const configPath = getConfigPath(); - return { success: true, email, configPath }; - } - - if (data.error === 'authorization_pending') continue; - if (data.error === 'slow_down') { - pollInterval += 5000; - continue; - } - - const errField: unknown = data.error; - const errorMsg = - typeof errField === 'string' ? errField : 'Authorization failed'; - return { success: false, error: errorMsg }; - } - - return { - success: false, - error: 'Authorization timed out. Please try again.', - }; - } - - // Standard path: delegate to pollForToken - const pollResult = await pollForToken(deviceCode, { - url: urlOption, - fetch: fetchOption, - timeoutMs, - intervalMs, + const outcome = await completeDeviceLogin(authorization.deviceCode, { + url: appUrl, + fetch: fetchFn, + timeoutMs: authorization.expiresIn * 1000 + POLL_TIMEOUT_BUFFER_MS, + // Clamped here, at the one place a server's number enters the loop. The + // helper takes its caller's interval as stated, which is what keeps a + // stated 0 from becoming a poll flood without making the helper's own + // option untestably slow. + intervalMs: + options.pollIntervalMs ?? + Math.max(MIN_SERVER_POLL_INTERVAL_MS, authorization.interval * 1000), + ...(options.maxPollAttempts !== undefined + ? { maxPollAttempts: options.maxPollAttempts } + : {}), }); - if (pollResult.success) { - return { - success: true, - email: pollResult.email, - configPath: pollResult.configPath, - }; - } - - if (pollResult.status === 'error') { - return { success: false, error: pollResult.error }; + switch (outcome.status) { + case 'ok': { + // Read back rather than returned: `completeDeviceLogin` owns the config + // file, so what is on disk is the only account this session belongs to. + const email = readConfig()?.email; + return { + success: true, + ...(email ? { email } : {}), + configPath: getConfigPath(), + }; + } + case 'denied': + return { success: false, error: 'Authorization was denied.' }; + case 'error': + return { success: false, error: outcome.error }; + default: + return { success: false, error: TIMED_OUT }; } - - return { - success: false, - error: 'Authorization timed out. Please try again.', - }; } diff --git a/packages/cli/src/commands/logout/index.ts b/packages/cli/src/commands/logout/index.ts index a16f4beea..d6825e292 100644 --- a/packages/cli/src/commands/logout/index.ts +++ b/packages/cli/src/commands/logout/index.ts @@ -1,5 +1,12 @@ import { createCLILogger } from '../../core/cli-logger.js'; -import { deleteConfig, getConfigPath } from '../../lib/config-file.js'; +import { revokeRefreshToken } from '../../core/oauth-client.js'; +import { + deleteConfig, + getConfigPath, + readConfig, + resolveAppUrl, + type WalkerOSConfig, +} from '../../lib/config-file.js'; import type { GlobalOptions } from '../../types/global.js'; export interface LogoutCommandOptions extends GlobalOptions { @@ -11,13 +18,18 @@ export async function logoutCommand( ): Promise { const logger = createCLILogger(options); - const deleted = deleteConfig(); + const { deleted, superseded } = await logout(); const configPath = getConfigPath(); if (options.json) { - logger.json({ success: true, deleted }); + logger.json({ success: true, deleted, superseded }); + } else if (superseded) { + logger.info( + 'A newer session was stored while logging out, and was kept. ' + + 'Run `walkeros auth logout` again to remove it.', + ); } else if (deleted) { - logger.info(`Logged out. Token removed from ${configPath}`); + logger.info(`Logged out. Session removed from ${configPath}`); } else { logger.info('No stored credentials found.'); } @@ -25,7 +37,48 @@ export async function logoutCommand( process.exit(0); } -export async function logout(): Promise<{ deleted: boolean }> { - const deleted = deleteConfig(); - return { deleted }; +export interface LogoutResult { + deleted: boolean; + /** A different session reached the config while the revocation was in flight. */ + superseded: boolean; +} + +/** Whether two reads of the config carry the same session. */ +function sameSession( + before: WalkerOSConfig | null, + after: WalkerOSConfig, +): boolean { + return ( + before?.accessToken === after.accessToken && + before?.refreshToken === after.refreshToken && + before?.token === after.token + ); +} + +/** + * Revoke the stored refresh token, then drop the local config. + * + * Revocation first, because deleting the file alone would leave a credential + * alive on the server that nothing can ever reach to retire. It is best + * effort: a logout on a plane still has to clear the machine. + */ +export async function logout(): Promise { + const before = readConfig(); + + if (before?.refreshToken) { + await revokeRefreshToken(resolveAppUrl(), before.refreshToken); + } + + // Re-read: revocation is a network round trip, and a login that finished + // inside it stored a session this logout never saw. Deleting the file would + // take that session with it, so the newer credential wins. + // + // It narrows the window rather than closing it. Only the token refresh takes + // the config lock, and holding it across the revocation would be a protocol + // change, not a check. + const after = readConfig(); + if (after && !sameSession(before, after)) + return { deleted: false, superseded: true }; + + return { deleted: deleteConfig(), superseded: false }; } diff --git a/packages/cli/src/commands/observe/index.ts b/packages/cli/src/commands/observe/index.ts index 76b91bd7b..72d889fbb 100644 --- a/packages/cli/src/commands/observe/index.ts +++ b/packages/cli/src/commands/observe/index.ts @@ -2,7 +2,7 @@ import { requireProjectId } from '../../core/auth.js'; import { apiFetch } from '../../core/http.js'; import { handleCliError, throwApiResponseError } from '../../core/api-error.js'; import { writeResult } from '../../core/output.js'; -import { resolveToken } from '../../lib/config-file.js'; +import { credentialSource } from '../../core/auth.js'; import type { GlobalOptions } from '../../types/global.js'; import type { components } from '../../types/api.gen.js'; import { getFlow } from '../flows/index.js'; @@ -325,7 +325,7 @@ export async function observeStartCommand( ): Promise { try { // No credentials at all: funnel straight to login, no network round trip. - if (!resolveToken()?.token) { + if (credentialSource() === null) { printLoginCta(); return; } diff --git a/packages/cli/src/config/utils.ts b/packages/cli/src/config/utils.ts index 78cbc71cd..3a44b470f 100644 --- a/packages/cli/src/config/utils.ts +++ b/packages/cli/src/config/utils.ts @@ -5,7 +5,9 @@ import fs from 'fs-extra'; import path from 'path'; import { mergeAuthHeaders } from '../core/http.js'; -import { resolveToken } from '../lib/config-file.js'; +import { resolveAccessToken } from '../core/auth.js'; +import { resolveAppUrl } from '../lib/config-file.js'; +import { requireSecureUrl } from '../lib/secure-url.js'; /** * Check if a string is a valid URL @@ -23,15 +25,37 @@ export function isUrl(str: string): boolean { } /** - * Fetch content from a URL as a string, with auth headers. + * Whether a URL points at the configured walkerOS app. + * + * Compares origins rather than prefixes: a host such as + * `https://app.walkeros.io.example.com` starts with the app URL but belongs to + * somebody else, and the port is part of who a host is. + */ +function isAppOrigin(url: string): boolean { + try { + return new URL(url).origin === new URL(resolveAppUrl()).origin; + } catch { + return false; + } +} + +/** + * Fetch content from a URL as a string. * Shared helper for all URL-loading paths. * + * The session bearer goes to the walkerOS app and nowhere else. A config input + * is any URL the person names, so attaching auth to all of them would hand + * their walkerOS session to whichever host they were pointed at. + * * @param url - HTTP/HTTPS URL to fetch * @returns Response body as a string * @throws Error if fetch fails or response is not OK */ export async function fetchContentString(url: string): Promise { - const token = resolveToken()?.token; + const token = isAppOrigin(url) ? await resolveAccessToken() : null; + // Only when a bearer is going along: an unauthenticated config download is + // the person naming any URL they like, and http is theirs to choose. + if (token) requireSecureUrl(url); const response = await fetch(url, { headers: mergeAuthHeaders(token), }); diff --git a/packages/cli/src/core/__tests__/cli-logger-config.test.ts b/packages/cli/src/core/__tests__/cli-logger-config.test.ts index 788d9b832..96c596b85 100644 --- a/packages/cli/src/core/__tests__/cli-logger-config.test.ts +++ b/packages/cli/src/core/__tests__/cli-logger-config.test.ts @@ -43,7 +43,9 @@ describe('createCLILoggerConfig (collector ring tap)', () => { const snapshot = errorRing.snapshot(); expect(snapshot).toHaveLength(1); - expect(snapshot[0].message).toBe('[bq] Push failed'); + expect(snapshot[0].message).toBe( + '[bq] Push failed {"event":"order complete"}', + ); expect(snapshot[0].count).toBe(1); }); diff --git a/packages/cli/src/core/__tests__/cli-logger.test.ts b/packages/cli/src/core/__tests__/cli-logger.test.ts index c170c337c..2112499ff 100644 --- a/packages/cli/src/core/__tests__/cli-logger.test.ts +++ b/packages/cli/src/core/__tests__/cli-logger.test.ts @@ -56,3 +56,108 @@ describe('createCLILogger onLine hook', () => { expect(logSpy).toHaveBeenCalledWith('hello'); }); }); + +describe('handler context serialization', () => { + let errorSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + errorSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('appends serialized context to the line, on console and the onLine tap', () => { + const captured: string[] = []; + const logger = createCLILogger({ + onLine: (_level, message) => { + captured.push(message); + }, + }); + + logger.scope('gcp-bigquery').error('connection error', { + error: 'Total timeout exceeded', + code: 4, + name: 'GoogleError', + }); + + const expected = + '[gcp-bigquery] connection error ' + + '{"error":"Total timeout exceeded","code":4,"name":"GoogleError"}'; + expect(captured).toEqual([expected]); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('"code":4')); + }); + + it('leaves lines without context untouched (no trailing braces)', () => { + const captured: string[] = []; + const logger = createCLILogger({ + onLine: (_level, message) => { + captured.push(message); + }, + }); + + logger.error('boom'); + + expect(captured).toEqual(['boom']); + }); + + it('redacts secret-shaped values inside the serialized context', () => { + const captured: string[] = []; + const logger = createCLILogger({ + onLine: (_level, message) => { + captured.push(message); + }, + }); + + // A >=20-char mixed-alphanumeric run trips the standalone-token rule in + // scrubSecrets, proving redaction runs AFTER context is appended. + logger.error('auth failed', { token: 'c2VjcmV0dG9rZW4xMjM0NTY3ODk' }); + + expect(captured[0]).not.toContain('c2VjcmV0'); + expect(captured[0]).toContain('***'); + }); + + it('does not throw on circular context and emits a fallback marker', () => { + const captured: string[] = []; + const logger = createCLILogger({ + onLine: (_level, message) => { + captured.push(message); + }, + }); + + const circular: Record = {}; + circular.self = circular; + + expect(() => logger.error('boom', circular)).not.toThrow(); + expect(captured[0]).toBe('boom [unserializable context]'); + }); + + it('pins the serialization contract: primitive-valued context round-trips, raw Error is documented-unsupported', () => { + const captured: string[] = []; + const logger = createCLILogger({ + onLine: (_level, message) => { + captured.push(message); + }, + }); + + // The errorMeta pattern (Task 3): primitive values, fully serialized. + logger.error('connection error', { + error: 'Deadline exceeded', + name: 'GoogleError', + code: 4, + }); + expect(captured[0]).toBe( + 'connection error {"error":"Deadline exceeded","name":"GoogleError","code":4}', + ); + + // Raw Error instances are unsupported input: JSON semantics apply (a plain + // Error has no enumerable own props -> {}). Pinned so the behavior is a + // documented contract, not a surprise. + logger.error('bad call site', { err: new Error('nope') }); + expect(captured[1]).toBe('bad call site {"err":{}}'); + }); +}); diff --git a/packages/cli/src/core/__tests__/contract.test.ts b/packages/cli/src/core/__tests__/contract.test.ts index 351dcd706..de330a019 100644 --- a/packages/cli/src/core/__tests__/contract.test.ts +++ b/packages/cli/src/core/__tests__/contract.test.ts @@ -75,9 +75,38 @@ function mockHealth(body: unknown, ok = true): void { })) as unknown as typeof fetch; } +/** + * Like `mockHealth`, but records the URLs the probe fetched so a test can + * assert WHICH app was probed, not only what the body said. + */ +function mockHealthCapturingUrls(body: unknown): string[] { + const urls: string[] = []; + const mock: typeof fetch = async (input) => { + urls.push(String(input)); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + global.fetch = mock; + return urls; +} + describe('fetchHealth', () => { afterEach(() => jest.restoreAllMocks()); + it('probes the baseUrl it is given', async () => { + const urls = mockHealthCapturingUrls({ status: 'ok' }); + await fetchHealth('https://passed.test'); + expect(urls).toEqual(['https://passed.test/api/health']); + }); + + it('probes the locally resolved app URL when no baseUrl is given', async () => { + const urls = mockHealthCapturingUrls({ status: 'ok' }); + await fetchHealth(); + expect(urls).toEqual(['https://app.test/api/health']); + }); + it('parses contractVersion and contractHash defensively', async () => { mockHealth({ status: 'ok', @@ -112,6 +141,34 @@ describe('fetchHealth', () => { describe('compareContract', () => { afterEach(() => jest.restoreAllMocks()); + it('probes input.baseUrl instead of the locally resolved app URL', async () => { + // A caller that is not the local CLI (the MCP's hosted door) names its own + // backend. Without this the verdict would describe whatever the local + // machine resolves, which on a hosted door is production. + const urls = mockHealthCapturingUrls({ + status: 'ok', + contractVersion: '1.0.0', + contractHash: 'BAKED_HASH', + }); + const out = await compareContract({ + bakedVersion: '1.0.0', + bakedHash: 'BAKED_HASH', + baseUrl: 'https://stage.app.walkeros.io', + }); + expect(urls).toEqual(['https://stage.app.walkeros.io/api/health']); + expect(out.verdict).toBe('in-sync'); + }); + + it('probes the locally resolved app URL when baseUrl is omitted', async () => { + const urls = mockHealthCapturingUrls({ + status: 'ok', + contractVersion: '1.0.0', + contractHash: 'BAKED_HASH', + }); + await compareContract({ bakedVersion: '1.0.0', bakedHash: 'BAKED_HASH' }); + expect(urls).toEqual(['https://app.test/api/health']); + }); + it('in-sync when live hash equals baked hash', async () => { mockHealth({ status: 'ok', diff --git a/packages/cli/src/core/api-client.ts b/packages/cli/src/core/api-client.ts index ee5fcc9cf..9aeea1fda 100644 --- a/packages/cli/src/core/api-client.ts +++ b/packages/cli/src/core/api-client.ts @@ -1,7 +1,8 @@ import createClient from 'openapi-fetch'; import type { paths } from '../types/api.gen.js'; -import { getToken } from './auth.js'; +import { resolveAccessToken } from './auth.js'; import { resolveAppUrl } from '../lib/config-file.js'; +import { requireSecureUrl } from '../lib/secure-url.js'; import { clientContextHeaders } from './client-context.js'; import { bakedContractVersion } from './contract.js'; @@ -41,24 +42,33 @@ export function resetDriftWarning(): void { } export function createApiClient() { - const token = getToken(); - if (!token) throw new Error('WALKEROS_TOKEN not set.'); - // Note: openapi-fetch fixes headers at createClient time. The CLI entry // point (and MCP boot path) call setClientContext before any API client is // constructed, so the client-context headers captured here are stable. const client = createClient({ baseUrl: resolveAppUrl(), headers: { - Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...clientContextHeaders(), }, }); - // Surface contract drift once per process from any response's version - // headers. openapi-fetch ^0.17 supports `use({ onResponse })`. client.use({ + // Authorization is attached per request, not at construction: the stdio + // MCP server builds one client and keeps it for hours, so a token captured + // here would go stale and never pick up a refresh. + async onRequest({ request }) { + const token = await resolveAccessToken(); + if (!token) + throw new Error('Not authenticated. Run `walkeros auth login` first.'); + // Checked against the outgoing URL rather than the base one, so a path + // that resolved somewhere else still cannot take the bearer with it. + requireSecureUrl(request.url); + request.headers.set('Authorization', `Bearer ${token}`); + return request; + }, + // Surface contract drift once per process from any response's version + // headers. openapi-fetch ^0.17 supports `use({ onResponse })`. onResponse({ response }) { emitDriftWarning(response.headers); return undefined; diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 7c920ff2e..be76842ec 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -1,23 +1,166 @@ import { + readConfig, + writeConfig, + clearAuthFields, resolveToken, resolveDeployToken, + resolveAppUrl, getDefaultProject, + type WalkerOSConfig, } from '../lib/config-file.js'; +import { withConfigLock } from '../lib/config-lock.js'; +import { refreshTokens } from './oauth-client.js'; -export function getToken(): string | undefined { - const result = resolveToken(); - return result?.token; +/** + * Refresh this far ahead of the stated expiry. It absorbs clock skew between + * the machine and the server plus the flight time of the request the token is + * about to be spent on, so a token is never handed out with seconds to live. + */ +const REFRESH_SKEW_MS = 60_000; + +let legacyNoticeShown = false; + +/** Test-only: reset the once-per-process guard on the legacy token notice. */ +export function resetLegacyTokenNotice(): void { + legacyNoticeShown = false; +} + +/** + * Announce the legacy static token once, on first use. + * + * Once per process, not once per call: a single command can resolve a token + * many times, and a notice printed on each would be noise the person learns + * to scroll past. It is printed at all because a migrated token can carry a + * year of expiry, so nothing else would ever prompt the switch. + */ +function noticeLegacyToken(): void { + if (legacyNoticeShown) return; + legacyNoticeShown = true; + process.stderr.write( + 'walkerOS: using a static token from your config. ' + + 'Run `walkeros auth login` to switch to a session that refreshes automatically.\n', + ); } -export function getAuthHeaders(): Record { - const token = getToken(); +/** Whether the stored access token has enough life left to be worth using. */ +function isFresh(config: WalkerOSConfig, nowMs: number): boolean { + if (!config.accessToken || !config.accessTokenExpiresAt) return false; + const expiresAt = Date.parse(config.accessTokenExpiresAt); + if (Number.isNaN(expiresAt)) return false; + return expiresAt - nowMs > REFRESH_SKEW_MS; +} + +/** + * Resolve a bearer for an API call, refreshing the stored session when needed. + * + * Priority: `WALKEROS_TOKEN`, then a legacy static token, then the OAuth + * session. Returns null when nothing can be resolved, which callers render as + * "run `walkeros auth login`". + * + * Throws when a refresh was needed but could not be carried out, which is a + * different problem from having no session and must not be reported as one. + */ +export async function resolveAccessToken(opts?: { + fetch?: typeof fetch; + now?: () => number; +}): Promise { + const envToken = process.env.WALKEROS_TOKEN; + if (envToken) return envToken; + + const now = opts?.now ?? Date.now; + const config = readConfig(); + if (!config) return null; + + if (config.token) { + noticeLegacyToken(); + return config.token; + } + + if (!config.refreshToken) { + return isFresh(config, now()) ? (config.accessToken ?? null) : null; + } + + if (isFresh(config, now())) return config.accessToken ?? null; + + return withConfigLock(async () => { + // Re-read under the lock. Another walkerOS process may have refreshed + // while we queued, and spending our copy of a single-use refresh token + // would invalidate the session it just established. + const current = readConfig(); + if (!current?.refreshToken) return current?.accessToken ?? null; + if (isFresh(current, now())) return current.accessToken ?? null; + + let rotated; + try { + rotated = await refreshTokens( + resolveAppUrl(), + current.refreshToken, + opts?.fetch, + ); + } catch (error) { + // Transient: the server was unreachable or faulted. The refresh token + // may well still be good, so it stays on disk and the next command + // tries again rather than forcing a browser round trip. + // + // Raised rather than returned as null, because null is how this function + // says "there is no session", which sends callers down the wrong path: + // they tell the person to run `walkeros auth login` and send the request + // unauthenticated, when the session is fine and only the network was not. + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not reach ${resolveAppUrl()} to refresh your session: ${reason}. ` + + 'Your saved session was kept, so try again once the connection is back.', + ); + } + + if (rotated === null) { + // The server rejected the refresh token itself. Nothing local can + // recover it, so drop the dead session. + clearAuthFields(); + return null; + } + + writeConfig({ + accessToken: rotated.accessToken, + accessTokenExpiresAt: rotated.accessTokenExpiresAt, + // A server that rotates no new refresh token leaves the current one in + // force; overwriting it with null would end the session on the next call. + refreshToken: rotated.refreshToken ?? current.refreshToken, + }); + + return rotated.accessToken; + }); +} + +/** + * Authorization header for the resolved credential, or an empty object when + * there is none. Async because resolving may have to refresh the session. + */ +export async function getAuthHeaders(): Promise> { + const token = await resolveAccessToken(); if (!token) return {}; return { Authorization: `Bearer ${token}` }; } +/** + * Where a credential would come from, without resolving or refreshing it. + * For commands that want to send someone to `walkeros auth login` before spending + * a network round trip. + */ +export function credentialSource(): 'env' | 'config' | null { + if (process.env.WALKEROS_TOKEN) return 'env'; + const config = readConfig(); + if (config?.token || config?.accessToken) return 'config'; + return null; +} + /** * Resolve token for runtime operations (run command, heartbeat, polling). * Priority: WALKEROS_DEPLOY_TOKEN > WALKEROS_TOKEN > config file + * + * Deliberately static and deliberately blind to the OAuth session: a runner is + * a long-lived container handed a token, with no refresh token and no config + * file to write a rotation back to. */ export function resolveRunToken(): string | null { return resolveDeployToken() ?? resolveToken()?.token ?? null; diff --git a/packages/cli/src/core/cli-logger.ts b/packages/cli/src/core/cli-logger.ts index 05aa704ce..28fbab062 100644 --- a/packages/cli/src/core/cli-logger.ts +++ b/packages/cli/src/core/cli-logger.ts @@ -42,9 +42,23 @@ export function createCLILoggerConfig( // at DEBUG, ERROR always reaches the handler (and the ring) even without // --verbose. level: Level.DEBUG, - handler: (level, message, _context, scope) => { + handler: (level, message, context, scope) => { // Build formatted message const scopePath = scope.length > 0 ? `[${scope.join(':')}] ` : ''; + // Serialize the structured context into the line so error details (gRPC + // status codes, row counts, target tables) reach stderr and the ring. + // Serialization happens BEFORE scrubSecrets so redaction covers context + // values too; the heartbeat path's 256-char cap stays as the wire + // backstop. A context that cannot stringify (circular) must never break + // logging. + let meta = ''; + if (Object.keys(context).length > 0) { + try { + meta = ` ${JSON.stringify(context)}`; + } catch { + meta = ' [unserializable context]'; + } + } // Redact secrets ONCE here, before BOTH the onLine ring tap and the // console.* output. stderr is shipped directly by Cockpit/Loki, so the // heartbeat-egress redactor alone (runtime/redact.ts) would miss it; doing @@ -52,7 +66,7 @@ export function createCLILoggerConfig( // (collector + steps, via the D1 wiring) on both paths. Length is // preserved here (no truncation); the heartbeat path applies the 256-char // wire cap separately as a backstop on already-redacted text. - const fullMessage = scrubSecrets(`${scopePath}${message}`); + const fullMessage = scrubSecrets(`${scopePath}${message}${meta}`); // Tap every line before any early return so no level is dropped from capture. try { diff --git a/packages/cli/src/core/contract.ts b/packages/cli/src/core/contract.ts index f733d062b..608b54140 100644 --- a/packages/cli/src/core/contract.ts +++ b/packages/cli/src/core/contract.ts @@ -90,14 +90,22 @@ const HEALTH_TIMEOUT_MS = 5000; /** * Tokenless reachability + contract probe of the app's PUBLIC `/api/health` - * route. Uses a plain `fetch` (never `createApiClient`, which throws logged - * out) and defensively parses the JSON body. Resolves `{ reachable: false }` + * route. Uses a plain `fetch` (never `createApiClient`, whose every request + * rejects without a credential) and defensively parses the JSON body. Resolves `{ reachable: false }` * only on a real network/timeout failure; a non-2xx status still counts as * reachable. + * + * `baseUrl` names the app to probe, without a trailing slash. Omitted, it + * falls back to `resolveAppUrl()`, the local machine's chain + * (`WALKEROS_APP_URL`, then the CLI config file, then the built-in default), + * which is what every `walkeros` binary invocation wants. A caller that is + * NOT the local CLI has to pass its own: an in-process host has no CLI config + * to read, so the fallback would silently probe a different backend than the + * one that caller talks to. */ -export async function fetchHealth(): Promise { +export async function fetchHealth(baseUrl?: string): Promise { try { - const res = await fetch(`${resolveAppUrl()}/api/health`, { + const res = await fetch(`${baseUrl ?? resolveAppUrl()}/api/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), }); const body: unknown = await res.json().catch(() => undefined); @@ -134,10 +142,16 @@ export interface ContractComparison { export interface CompareContractInput { bakedVersion?: string; bakedHash?: string; + /** + * The app to probe, without a trailing slash. Omitted, the probe resolves + * the local machine's app URL; see {@link fetchHealth}. + */ + baseUrl?: string; } /** * Compare the client's baked contract against the live app's `/api/health`. + * The app is `input.baseUrl` when given, otherwise the locally resolved one. * * - unreachable / missing `contractVersion`+`contractHash` → `unknown` * - baked hash == live hash → `in-sync` @@ -151,7 +165,7 @@ export async function compareContract( const bakedVersion = input.bakedVersion ?? bakedContractVersion; const bakedHash = input.bakedHash ?? bakedContractHash; - const health = await fetchHealth(); + const health = await fetchHealth(input.baseUrl); if ( !health.reachable || health.contractVersion === undefined || diff --git a/packages/cli/src/core/http.ts b/packages/cli/src/core/http.ts index 2ced302a6..6fca1eb4c 100644 --- a/packages/cli/src/core/http.ts +++ b/packages/cli/src/core/http.ts @@ -1,8 +1,6 @@ -import { - resolveAppUrl, - resolveToken, - resolveDeployToken, -} from '../lib/config-file.js'; +import { resolveAppUrl, resolveDeployToken } from '../lib/config-file.js'; +import { requireSecureUrl } from '../lib/secure-url.js'; +import { resolveAccessToken } from './auth.js'; import { clientContextHeaders } from './client-context.js'; /** @@ -46,13 +44,19 @@ function buildHeaders( /** * Authenticated fetch — resolves base URL + adds auth token. * Use for all API calls that require WALKEROS_TOKEN. + * + * The transport is checked only once a credential is actually going out. + * `resolveAppUrl` keeps answering with whatever is configured, which is what + * the paths that merely REPORT the target (diagnostics, health, telemetry) + * need from it. */ export async function apiFetch( path: string, init?: RequestInit, ): Promise { const baseUrl = resolveAppUrl(); - const token = resolveToken()?.token; + const token = await resolveAccessToken(); + if (token) requireSecureUrl(baseUrl); return fetch(`${baseUrl}${path}`, { ...init, headers: buildHeaders(token, init?.headers), @@ -86,11 +90,12 @@ export async function deployFetch( init?: RequestInit, ): Promise { const baseUrl = resolveAppUrl(); - const token = resolveDeployToken() ?? resolveToken()?.token; + const token = resolveDeployToken() ?? (await resolveAccessToken()); if (!token) throw new Error( 'No authentication token available. Set WALKEROS_DEPLOY_TOKEN or run walkeros auth login.', ); + requireSecureUrl(baseUrl); return fetch(`${baseUrl}${path}`, { ...init, headers: buildHeaders(token, init?.headers), diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index d6c79a56a..e41291596 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -7,7 +7,11 @@ export * from './utils.js'; export * from './local-packages.js'; export * from './input-detector.js'; export * from './stdin.js'; -export { getToken, getAuthHeaders } from './auth.js'; +export { + resolveAccessToken, + getAuthHeaders, + credentialSource, +} from './auth.js'; export { apiFetch, publicFetch, diff --git a/packages/cli/src/core/oauth-client.ts b/packages/cli/src/core/oauth-client.ts new file mode 100644 index 000000000..d2f2e6fd6 --- /dev/null +++ b/packages/cli/src/core/oauth-client.ts @@ -0,0 +1,314 @@ +import { z } from 'zod'; +import { requireSecureUrl } from '../lib/secure-url.js'; + +/** + * OAuth 2.1 client for the walkerOS authorization server. + * + * Pure HTTP with `fetch` injected: nothing here reads the config file or the + * environment, so the flows can be exercised without a machine state. + */ + +/** Public client id seeded for the CLI. It has no secret. */ +export const CLI_CLIENT_ID = 'walkeros-cli'; + +/** + * `offline_access` is what buys the refresh token; without it every command + * would send the person back to the browser once the access token expired. + */ +export const CLI_SCOPE = 'read write offline_access'; + +const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +/** + * Ceiling on a refresh request. It is deliberately below the 15 s after which + * `withConfigLock` treats a lock as abandoned: a refresh runs while holding + * that lock, so a request allowed to hang longer would have its own lock + * broken out from under it and race the process that took it next. + */ +const REFRESH_TIMEOUT_MS = 10_000; + +/** + * Ceiling on a revocation. Nothing waits on its answer, so this only bounds + * how long a logout stands still before clearing the machine. + */ +const REVOKE_TIMEOUT_MS = 5_000; + +/** + * Ceiling on the request that opens a device authorization. Nothing has been + * issued yet, so this only bounds how long `walkeros auth login` stands still + * before it can show a code, but a server that accepts the connection and then + * says nothing would otherwise hold it for the HTTP client's own default. + */ +const DEVICE_AUTHORIZATION_TIMEOUT_MS = 10_000; + +const FORM_HEADERS = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', +} as const; + +const DeviceAuthorizationSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.string().min(1), + verification_uri_complete: z.string().min(1).optional(), + expires_in: z.number().int().nonnegative(), + interval: z.number().int().nonnegative(), +}); + +const TokenSchema = z.object({ + access_token: z.string().min(1), + expires_in: z.number().int().nonnegative(), + refresh_token: z.string().min(1).optional(), +}); + +const ErrorSchema = z.object({ + error: z.string().min(1), + error_description: z.string().optional(), +}); + +export interface DeviceAuthorization { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number; + interval: number; +} + +export interface TokenSet { + accessToken: string; + accessTokenExpiresAt: string; + refreshToken: string | null; +} + +export type DevicePoll = + | { status: 'ok'; tokens: TokenSet } + | { status: 'pending' } + | { status: 'slow_down' } + | { status: 'denied' } + | { status: 'expired' } + | { status: 'error'; error: string }; + +/** Parse a response body as JSON, or null when it is not JSON at all. */ +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +/** + * The RFC 6749 section 5.2 error code carried by a failed response, or null + * when the body is not an OAuth error (a proxy's HTML, say). + */ +function errorCode(body: unknown): string | null { + const parsed = ErrorSchema.safeParse(body); + return parsed.success ? parsed.data.error : null; +} + +function describe(response: Response, body: unknown): string { + const parsed = ErrorSchema.safeParse(body); + if (parsed.success) { + return parsed.data.error_description + ? `${parsed.data.error}: ${parsed.data.error_description}` + : parsed.data.error; + } + return `HTTP ${response.status}`; +} + +/** + * Every request in this module either carries a credential or mints one, so + * the two rules that protect one are applied here rather than per call: + * plain http is refused off the local machine, and a redirect is an error + * rather than a hop, because following one would hand the token (or the code + * that buys it) to whichever host the answer named. + */ +function post( + fetchFn: typeof fetch, + url: string, + form: Record, + signal?: AbortSignal, +): Promise { + return fetchFn(requireSecureUrl(url), { + method: 'POST', + headers: { ...FORM_HEADERS }, + body: new URLSearchParams(form).toString(), + redirect: 'error', + ...(signal ? { signal } : {}), + }); +} + +function toTokenSet(body: unknown): TokenSet { + const parsed = TokenSchema.safeParse(body); + if (!parsed.success) throw new Error('Malformed token response'); + + return { + accessToken: parsed.data.access_token, + accessTokenExpiresAt: new Date( + Date.now() + parsed.data.expires_in * 1000, + ).toISOString(), + refreshToken: parsed.data.refresh_token ?? null, + }; +} + +/** + * RFC 8628 section 3.1. Ask for a device code and the URL to send the person + * to. Unauthenticated: the code is worth nothing until somebody approves it. + */ +export async function startDeviceAuthorization( + appUrl: string, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + const response = await post( + fetchFn, + `${appUrl}/api/oauth/device_authorization`, + { + client_id: CLI_CLIENT_ID, + scope: CLI_SCOPE, + // RFC 8707. The token comes back bound to the API, so a leaked CLI token + // cannot be replayed against the MCP resource. + resource: `${appUrl}/api`, + }, + AbortSignal.timeout(DEVICE_AUTHORIZATION_TIMEOUT_MS), + ); + + const body = await readJson(response); + if (!response.ok) throw new Error(describe(response, body)); + + const parsed = DeviceAuthorizationSchema.safeParse(body); + if (!parsed.success) + throw new Error('Malformed device authorization response'); + + return { + deviceCode: parsed.data.device_code, + userCode: parsed.data.user_code, + verificationUri: parsed.data.verification_uri, + verificationUriComplete: + parsed.data.verification_uri_complete ?? parsed.data.verification_uri, + expiresIn: parsed.data.expires_in, + interval: parsed.data.interval, + }; +} + +/** + * One poll of RFC 8628 section 3.4. Every outcome is a returned status rather + * than a throw, because four of them are ordinary steps of a flow that is + * still running. + * + * `signal` is how a caller keeps its own deadline: without one, a server that + * accepts the connection and then says nothing holds this call for the HTTP + * client's default, which is minutes. + */ +export async function pollDeviceToken( + appUrl: string, + deviceCode: string, + fetchFn: typeof fetch = globalThis.fetch, + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await post( + fetchFn, + `${appUrl}/api/oauth/token`, + { + grant_type: DEVICE_CODE_GRANT, + device_code: deviceCode, + client_id: CLI_CLIENT_ID, + }, + signal, + ); + } catch (error) { + // Only an abort of the caller's own signal is an outcome rather than a + // fault: the authorization is untouched, so it is still pending and the + // caller's loop decides whether there is room for another attempt. Any + // other transport failure is a real error and stays one. + if (signal?.aborted) return { status: 'pending' }; + throw error; + } + + const body = await readJson(response); + + if (response.ok) { + try { + return { status: 'ok', tokens: toTokenSet(body) }; + } catch (error) { + return { + status: 'error', + error: error instanceof Error ? error.message : String(error), + }; + } + } + + switch (errorCode(body)) { + case 'authorization_pending': + return { status: 'pending' }; + case 'slow_down': + return { status: 'slow_down' }; + case 'access_denied': + return { status: 'denied' }; + case 'expired_token': + return { status: 'expired' }; + default: + return { status: 'error', error: describe(response, body) }; + } +} + +/** + * Spend a refresh token for a new pair. + * + * `null` means the server rejected the token itself and only a fresh login + * can recover. Every other failure throws, so a transient fault stays + * distinguishable from a dead session and never costs the person their login. + */ +export async function refreshTokens( + appUrl: string, + refreshToken: string, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + const response = await post( + fetchFn, + `${appUrl}/api/oauth/token`, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CLI_CLIENT_ID, + }, + AbortSignal.timeout(REFRESH_TIMEOUT_MS), + ); + + const body = await readJson(response); + + if (!response.ok) { + if (errorCode(body) === 'invalid_grant') return null; + throw new Error(describe(response, body)); + } + + return toTokenSet(body); +} + +/** + * RFC 7009. Best effort by design: logout must clear the local config whether + * or not the server could be reached, and the server answers a revocation it + * cannot act on with 200 anyway. + */ +export async function revokeRefreshToken( + appUrl: string, + refreshToken: string, + fetchFn: typeof fetch = globalThis.fetch, +): Promise { + try { + await post( + fetchFn, + `${appUrl}/api/oauth/revoke`, + { + token: refreshToken, + token_type_hint: 'refresh_token', + client_id: CLI_CLIENT_ID, + }, + AbortSignal.timeout(REVOKE_TIMEOUT_MS), + ); + } catch { + // Offline, or the server is down. The local credential is still cleared. + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cb1da0817..24ef901f1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,16 +12,16 @@ export { runCommand } from './commands/run/index.js'; export { validateCommand } from './commands/validate/index.js'; export { loginCommand, - requestDeviceCode, - pollForToken, + login, + completeDeviceLogin, } from './commands/login/index.js'; export type { - DeviceCodeResult, - DeviceCodeOptions, - PollOptions, - PollResult, + LoginOptions, + LoginResult, + CompleteDeviceLoginOptions, + DeviceLoginResult, } from './commands/login/index.js'; -export { logoutCommand } from './commands/logout/index.js'; +export { logoutCommand, logout } from './commands/logout/index.js'; export { whoamiCommand } from './commands/auth/index.js'; export { listProjectsCommand, @@ -83,7 +83,14 @@ export type { } from './commands/push/index.js'; export { run } from './commands/run/index.js'; export { validate } from './commands/validate/index.js'; -export { getToken, getAuthHeaders, requireProjectId } from './core/auth.js'; +export { + resolveAccessToken, + getAuthHeaders, + credentialSource, + requireProjectId, +} from './core/auth.js'; +export { startDeviceAuthorization } from './core/oauth-client.js'; +export type { DeviceAuthorization } from './core/oauth-client.js'; export { apiFetch, publicFetch, @@ -169,6 +176,43 @@ export type { GetObserveSessionOptions, EndObserveSessionOptions, } from './commands/observe/index.js'; +export { + listReleases, + getRelease, + listStepHistory, + setReleaseRationale, + listThreads, + createThread, + addThreadMessage, + listKnowledge, +} from './commands/hub/index.js'; +export type { + ListReleasesOptions, + GetReleaseOptions, + ReleaseRef, + ReleaseIndexResponse, + ReleaseRationaleSummary, + ReleaseDiffResponse, + ReleaseDetailResponse, + ListStepHistoryOptions, + SetReleaseRationaleOptions, + ListThreadsOptions, + CreateThreadOptions, + AddThreadMessageOptions, + ListKnowledgeOptions, + ThreadAnchorType, + ThreadStatus, +} from './commands/hub/index.js'; +export { + listFrames, + listPageFrames, + getFrame, +} from './commands/frames/index.js'; +export type { + ListFramesOptions, + ListPageFramesOptions, + GetFrameOptions, +} from './commands/frames/index.js'; export { listSecrets, createSecret, @@ -185,6 +229,7 @@ export type { FeedbackOptions } from './commands/feedback/index.js'; export { readConfig, writeConfig, + clearAuthFields, deleteConfig, resolveToken, resolveAppUrl, diff --git a/packages/cli/src/lib/config-file.ts b/packages/cli/src/lib/config-file.ts index ed04ed5bf..49e6c08ea 100644 --- a/packages/cli/src/lib/config-file.ts +++ b/packages/cli/src/lib/config-file.ts @@ -4,12 +4,26 @@ import { mkdirSync, unlinkSync, existsSync, + chmodSync, + renameSync, } from 'fs'; +import { randomBytes } from 'crypto'; import { join } from 'path'; import { homedir } from 'os'; export interface WalkerOSConfig { + /** + * Static bearer written by the pre-OAuth CLI. Honored until it expires, and + * the first use in a process prints a one-line notice naming + * `walkeros auth login`, which replaces it with a refreshable session. + */ token?: string; + /** Short-lived bearer from the device authorization grant. */ + accessToken?: string; + /** ISO 8601 instant at which `accessToken` stops being accepted. */ + accessTokenExpiresAt?: string; + /** Single-use credential that buys a new `accessToken`. */ + refreshToken?: string; email?: string; appUrl?: string; anonymousFeedback?: boolean; @@ -58,14 +72,75 @@ export function readConfig(): WalkerOSConfig | null { } /** - * Write config to disk with 0600 permissions + * Replace the config file wholesale, atomically and with 0600 permissions. + * + * A reader that catches the file mid-write would see truncated JSON and treat + * the person as logged out, so the content is written to a temp file and + * renamed, which is atomic within a directory. + * + * The temp path is unique per write. Only the token refresh holds the config + * lock, so two ordinary writers (a login and a `telemetry enable`, say) can be + * in here at once: on one shared name they would write over each other's temp + * file and rename it twice, and the slower one would fail outright when the + * faster renamed the file out from under its `chmod`. */ -export function writeConfig(config: WalkerOSConfig): void { +function replaceConfigFile(config: WalkerOSConfig): void { const dir = getConfigDir(); - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); const configPath = getConfigPath(); - writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + const tempPath = `${configPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + // `writeFileSync`'s mode is masked by the process umask, so it alone does + // not guarantee 0600 on the file the rename puts in place. + chmodSync(tempPath, 0o600); + renameSync(tempPath, configPath); + } catch (error) { + // A unique name is never reused, so a temp left behind by a failure would + // sit in the config directory forever. + try { + unlinkSync(tempPath); + } catch { + // Never created, or already renamed into place. + } + throw error; + } +} + +/** + * Merge `config` into the stored config and write the result. + * + * Merging rather than replacing, because the file holds fields owned by + * unrelated commands: a writer that knows only about tokens would otherwise + * drop `defaultProjectId`, `installationId`, `telemetryEnabled` and + * `anonymousFeedback` every time somebody logs in. + * + * A key passed explicitly as `undefined` is removed from the written file, + * which is how login drops the legacy static token it replaces. + */ +export function writeConfig(config: WalkerOSConfig): void { + replaceConfigFile({ ...(readConfig() ?? {}), ...config }); +} + +/** + * Remove every credential field, keeping the rest of the config. + * + * Used when the stored session is known to be dead, so the next command can + * say "run `walkeros auth login`" instead of failing against the API. + */ +export function clearAuthFields(): void { + const config = readConfig(); + if (!config) return; + const { + token: _token, + accessToken: _accessToken, + accessTokenExpiresAt: _accessTokenExpiresAt, + refreshToken: _refreshToken, + email: _email, + ...rest + } = config; + replaceConfigFile(rest); } /** @@ -79,8 +154,7 @@ export function writeTelemetryOnlyConfig(partial: { installationId?: string; telemetryEnabled?: boolean; }): void { - const existing = readConfig() ?? {}; - writeConfig({ ...existing, ...partial }); + writeConfig(partial); } /** @@ -121,7 +195,7 @@ export function getFeedbackPreference(): boolean | undefined { export function setDefaultProject(projectId: string): void { const config = readConfig(); if (!config) { - throw new Error('Not authenticated. Run `walkeros login` first.'); + throw new Error('Not authenticated. Run `walkeros auth login` first.'); } writeConfig({ ...config, defaultProjectId: projectId }); } @@ -143,7 +217,7 @@ export function clearDefaultProject(): void { const config = readConfig(); if (!config) return; const { defaultProjectId: _removed, ...rest } = config; - writeConfig(rest); + replaceConfigFile(rest); } /** diff --git a/packages/cli/src/lib/config-lock.ts b/packages/cli/src/lib/config-lock.ts new file mode 100644 index 000000000..567c1cca4 --- /dev/null +++ b/packages/cli/src/lib/config-lock.ts @@ -0,0 +1,101 @@ +import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from 'fs'; +import { getConfigDir, getConfigPath } from './config-file.js'; + +/** + * A lock older than this is treated as abandoned. It bounds the damage a + * process killed mid-refresh can do: without it, one crash leaves every later + * command waiting for a holder that will never return. + */ +const STALE_MS = 15_000; + +/** Gap between acquire attempts. */ +const RETRY_MS = 100; + +/** How long to keep trying before giving up on a lock somebody else holds. */ +const TIMEOUT_MS = 10_000; + +/** Path of the lock guarding the config file. */ +export function getConfigLockPath(): string { + return `${getConfigPath()}.lock`; +} + +function hasCode(value: unknown): value is { code: unknown } { + return typeof value === 'object' && value !== null && 'code' in value; +} + +/** + * Matched on the `code` property rather than on `instanceof Error`, which is + * unreliable across realm boundaries (a test runner's module sandbox is one). + */ +function isAlreadyLocked(error: unknown): boolean { + return hasCode(error) && error.code === 'EEXIST'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Remove the lock when its holder has clearly gone away. Returns whether the + * lock was removed, so the caller can retry immediately rather than sleeping. + */ +function breakIfStale(lockPath: string): boolean { + try { + const age = Date.now() - statSync(lockPath).mtimeMs; + if (age < STALE_MS) return false; + unlinkSync(lockPath); + return true; + } catch { + // Gone between the stat and the unlink, or never there: either way the + // next acquire attempt is the answer. + return false; + } +} + +/** + * Run `fn` while holding an exclusive lock on the config file. + * + * Several walkerOS processes can share one config (a shell, an editor's MCP + * server, a watch loop). Without a lock, two of them noticing an expired + * access token at the same moment would both spend the single-use refresh + * token, and the loser's rotation would invalidate the winner's session. + * + * The lock is a file created with `O_EXCL`, which is atomic on every platform + * the CLI runs on and needs no daemon. + */ +export async function withConfigLock(fn: () => Promise): Promise { + const lockPath = getConfigLockPath(); + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + + const deadline = Date.now() + TIMEOUT_MS; + + for (;;) { + let handle: number; + try { + handle = openSync(lockPath, 'wx', 0o600); + } catch (error) { + if (!isAlreadyLocked(error)) throw error; + if (breakIfStale(lockPath)) continue; + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for the walkerOS config lock at ${lockPath}. ` + + 'Remove the file if no other walkeros process is running.', + ); + } + await delay(RETRY_MS); + continue; + } + + closeSync(handle); + try { + return await fn(); + } finally { + try { + unlinkSync(lockPath); + } catch { + // Another process broke the lock as stale while we held it. Nothing to + // release, and failing here would mask the callback's own result. + } + } + } +} diff --git a/packages/cli/src/lib/secure-url.ts b/packages/cli/src/lib/secure-url.ts new file mode 100644 index 000000000..a59abd64b --- /dev/null +++ b/packages/cli/src/lib/secure-url.ts @@ -0,0 +1,48 @@ +/** + * Whether a host is the local machine. + * + * Loopback traffic never reaches a network segment, so there is nobody in the + * path to read a token off it. RFC 8252 section 8.3 and OAuth 2.1 both carve + * out exactly this case, and `http://localhost:3000` is the documented + * walkerOS development flow. + */ +function isLoopback(hostname: string): boolean { + // `URL` lowercases the hostname and keeps the brackets on an IPv6 literal. + return ( + hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' + ); +} + +/** + * Refuse a URL that would carry a walkerOS credential in the clear. + * + * Every bearer the CLI holds (session, refresh, deploy) is sent to whatever + * host the app URL names, and that string is user-settable: `WALKEROS_APP_URL`, + * the config file, and `--url` all feed it. Over plain http to a remote host, + * anything on the path reads the token. + * + * Applied where a credential is ATTACHED, never inside `resolveAppUrl`. That + * resolver is the tempting single funnel, but it also feeds the paths that + * merely REPORT the target: telemetry resolves it under a top-level await + * with no `try`, and diagnostics and the health probe exist to NAME a bad + * app URL. A resolver that throws poisons the error reporting. + * + * Returns the URL, so a caller can pass its value straight through. A string + * that is not a URL at all is returned untouched: it fails at the request + * itself, and inferring a scheme for it would only hide that. + */ +export function requireSecureUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + + if (parsed.protocol !== 'http:' || isLoopback(parsed.hostname)) return url; + + throw new Error( + `Refusing to send walkerOS credentials over plain http to ${url}. ` + + 'Use https, or localhost / 127.0.0.1 / [::1] for local development.', + ); +} diff --git a/packages/cli/src/runtime/__tests__/runner-logger-tap.test.ts b/packages/cli/src/runtime/__tests__/runner-logger-tap.test.ts index a52d619ff..55ddc0343 100644 --- a/packages/cli/src/runtime/__tests__/runner-logger-tap.test.ts +++ b/packages/cli/src/runtime/__tests__/runner-logger-tap.test.ts @@ -110,7 +110,9 @@ export default async function(context = {}) { const snapshot = errorRing.snapshot(); expect(snapshot).toHaveLength(1); - expect(snapshot[0].message).toBe('[bq] Push failed'); + expect(snapshot[0].message).toBe( + '[bq] Push failed {"event":"order complete"}', + ); expect(handle.file).toBe(bundle); }); diff --git a/packages/cli/src/types/api.gen.d.ts b/packages/cli/src/types/api.gen.d.ts index 82381ac02..89c062b78 100644 --- a/packages/cli/src/types/api.gen.d.ts +++ b/packages/cli/src/types/api.gen.d.ts @@ -449,158 +449,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/auth/device/code': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Request device code - * @description Generate a device code and user code for the device authorization flow. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Device code generated */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['DeviceCodeResponse']; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/auth/device/approve': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Approve device code - * @description Approve a device authorization request using the user code. Requires authentication. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['ApproveDeviceRequest']; - }; - }; - responses: { - /** @description Device approved */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApproveDeviceResponse']; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorResponse']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorResponse']; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/auth/device/token': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Poll device token - * @description Poll for authorization status using the device code. Returns a token when approved. - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['DeviceTokenRequest']; - }; - }; - responses: { - /** @description Authorization approved */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['DeviceTokenResponse']; - }; - }; - /** @description Pending, slow down, or expired */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorResponse']; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/projects': { parameters: { query?: never; @@ -2835,7 +2683,7 @@ export interface paths { put?: never; /** * Deploy settings - * @description Start a deployment for a specific settings entry. Detects platform from the settings. + * @description Start a deployment for a specific settings entry. Detects platform from the settings. The body is optional and carries only `humanText`, the reason for the change, which becomes the description of the release this deploy produces; it is ignored when the release already has one. */ post: { parameters: { @@ -2848,7 +2696,11 @@ export interface paths { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': components['schemas']['DeploySettingsRequest']; + }; + }; responses: { /** @description Deployment started */ 201: { @@ -4042,8 +3894,8 @@ export interface paths { cookie?: never; }; /** - * List my tokens - * @description List all API tokens for the authenticated user. Returns summaries (no raw token values). + * List my automation tokens + * @description The caller's live automation tokens, with the scope and audience each carries. No raw token value is ever returned; `tokenPrefix` is the only fragment of one that survives issuance. A connected app's access token lives in the same store and is deliberately absent: it is taken back by disconnecting the app. */ get: { parameters: { @@ -4054,13 +3906,13 @@ export interface paths { }; requestBody?: never; responses: { - /** @description List of tokens */ + /** @description List of automation tokens */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListApiTokensResponse']; + 'application/json': components['schemas']['ListAutomationTokensResponse']; }; }; /** @description Unauthorized */ @@ -4076,8 +3928,8 @@ export interface paths { }; put?: never; /** - * Create token - * @description Create a new API token. The raw token is returned once and cannot be retrieved again. + * Create automation token + * @description Mint an automation token for the authenticated user. The audience is `api` and `mcp`, so one token works against REST and against `/api/mcp`, and the chosen scope decides how far it gets at either: `read` is refused every non-safe REST method with 403 `INSUFFICIENT_SCOPE`. The raw token is returned once and cannot be retrieved again, so the answer carries `Cache-Control: no-store`. */ post: { parameters: { @@ -4088,7 +3940,7 @@ export interface paths { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateApiTokenRequest']; + 'application/json': components['schemas']['CreateAutomationTokenRequest']; }; }; responses: { @@ -4098,7 +3950,7 @@ export interface paths { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateApiTokenResponse']; + 'application/json': components['schemas']['CreateAutomationTokenResponse']; }; }; /** @description Validation error */ @@ -4136,6 +3988,52 @@ export interface paths { patch?: never; trace?: never; }; + '/api/tokens/revoke-all': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke all access + * @description Revoke every grant this person holds, the tokens hanging from them, and every automation token they hold. Runner tokens survive: those are the credentials deployed flow containers run with, so revoking them would stop every container the person is running. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token cannot disconnect everything its owner has connected. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Access revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/tokens/{tokenId}': { parameters: { query?: never; @@ -4147,8 +4045,8 @@ export interface paths { put?: never; post?: never; /** - * Revoke token - * @description Revoke an API token (soft delete via revokedAt timestamp). + * Revoke automation token + * @description Revoke one of the caller's tokens. Idempotent and scoped to the caller: an unknown id, another person's token and an already revoked one all answer 204, since a distinguishable answer would tell the caller which ids exist. */ delete: { parameters: { @@ -5987,8 +5885,9 @@ export interface paths { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['BillingDetailsResponse'] & - (Record | null); + 'application/json': + | components['schemas']['BillingDetailsResponse'] + | null; }; }; /** @description Unauthorized */ @@ -6576,13 +6475,14 @@ export interface paths { }; /** * List flow releases - * @description List the release history for a flow across all of its deployment lineages, newest first, paginated. Each entry is a deployed version joined to its parent deployment (slug and type). Requires member role. + * @description List the release history for a flow across all of its deployment lineages, newest first, paginated. Each entry is a deployed version joined to its parent deployment (slug and type). `rationale=true` joins each row's stored rationale summary on, which requires the `hub` feature; without it the `rationale` key is absent from every row rather than null, and no feature beyond member role is needed. Requires member role. */ get: { parameters: { query?: { limit?: number; offset?: number | null; + rationale?: 'true' | 'false'; }; header?: never; path: { @@ -6648,7 +6548,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/flows/{flowId}/releases/annotations': { + '/api/projects/{projectId}/flows/{flowId}/releases/{versionId}': { parameters: { query?: never; header?: never; @@ -6656,33 +6556,33 @@ export interface paths { cookie?: never; }; /** - * List release rationale - * @description Read the rationale attached to the given releases of a flow. `versionIds` is a comma-separated list of spine version ids (at most 100), all of which must belong to this flow. Releases without rationale are absent from the response. Requires member role. + * Read one release in full + * @description One release of this flow with its rationale and its diff. The path segment is either the spine version id (`ver_...`) or the flow-unique spine number, and the route decides which it was, so a caller holding only the number needs no lookup first. The diff is computed server-side from the two stored snapshots and is never accepted from a caller; its predecessor is the next LOWER spine number, not the previous row by time, because spine rows are reused across redeploys of identical content. `diff.text` is rendered from masked content, so an empty string can still mean the releases differ inside an inline secret: `diff.contentIdentical`, compared over the unmasked hashes, is the trustworthy answer. `diff` is null for the flow's oldest release. An unknown address, a sibling flow's version, and an autosave revision all answer 404 alike. Requires member role and the `hub` feature. */ get: { parameters: { - query: { - versionIds: string; - }; + query?: never; header?: never; path: { projectId: string; flowId: string; + /** @description Spine version id of the release (ver_...) or its spine number */ + versionId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Rationale for the requested releases */ + /** @description The release, its rationale, and its diff */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListVersionAnnotationsResponse']; + 'application/json': components['schemas']['ReleaseDetailResponse']; }; }; - /** @description Invalid version ids */ + /** @description Invalid release reference */ 400: { headers: { [name: string]: unknown; @@ -6729,40 +6629,49 @@ export interface paths { }; }; }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/flows/{flowId}/releases/{versionId}/content': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * Write release rationale - * @description Create or update the human rationale for one release of this flow. A null `humanText` clears it. The generated summary is machine-written and cannot be set through this route. The target must be a numbered release version of this flow, not an autosave revision. Requires member role. + * Read a release snapshot + * @description The flow config one release of this flow froze, addressed by its spine version id. This is the only route that serves a release snapshot: the positional `/versions/{versionNumber}` route numbers the autosave revisions, a disjoint set of rows, so a release number handed to it addresses an unrelated revision or nothing. Inline secret literals are masked. An unknown id, a sibling flow's version, and an autosave revision all answer 404 alike. Requires member role and the `hub` feature. */ - put: { + get: { parameters: { query?: never; header?: never; path: { projectId: string; flowId: string; + /** @description Spine version ID of the release (ver_...) */ + versionId: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example ver_a1b2c3d4 */ - versionId: string; - humanText: string | null; - }; - }; - }; + requestBody?: never; responses: { - /** @description The stored rationale */ + /** @description The release snapshot */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['UpsertVersionAnnotationResponse']; + 'application/json': components['schemas']['ReleaseContentResponse']; }; }; - /** @description Invalid body, or the target is not a release */ + /** @description Invalid version id */ 400: { headers: { [name: string]: unknown; @@ -6809,6 +6718,7 @@ export interface paths { }; }; }; + put?: never; post?: never; delete?: never; options?: never; @@ -6816,7 +6726,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/flows/{flowId}/threads': { + '/api/projects/{projectId}/flows/{flowId}/releases/annotations': { parameters: { query?: never; header?: never; @@ -6824,22 +6734,13 @@ export interface paths { cookie?: never; }; /** - * List discussion threads on a flow - * @description Threads anchored to things in this flow, most recently active first. `anchorType` and `anchorKey` narrow to one anchor and are only meaningful together. `includeMessages=true` attaches the messages; otherwise each thread carries `messageCount` alone. Attaching them holds the page to a smaller ceiling than the lean index and caps each thread at its newest 50 messages, with `hasMoreMessages` set when a thread holds more. Because that ceiling is below the `limit` a caller may pass, the response carries `hasMoreThreads`: a full page is not proof of a complete list. A resolved thread carries the release that settled it, and `resolvedByVersionId` is null once that release is gone, which is what `anchorLabel` is kept for. Requires member role. + * List release rationale + * @description Read the rationale attached to the given releases of a flow. `versionIds` is a comma-separated list of spine version ids (at most 100), all of which must belong to this flow. Releases without rationale are absent from the response. Requires member role. */ get: { parameters: { - query?: { - anchorType?: - | 'step' - | 'entity_action' - | 'release' - | 'contract' - | 'tag'; - anchorKey?: string; - status?: 'open' | 'resolved'; - includeMessages?: 'true' | 'false'; - limit?: number; + query: { + versionIds: string; }; header?: never; path: { @@ -6850,16 +6751,16 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Threads on this flow */ + /** @description Rationale for the requested releases */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListHubThreadsResponse']; + 'application/json': components['schemas']['ListVersionAnnotationsResponse']; }; }; - /** @description Invalid query */ + /** @description Invalid version ids */ 400: { headers: { [name: string]: unknown; @@ -6906,10 +6807,187 @@ export interface paths { }; }; }; - put?: never; /** - * Open a discussion thread - * @description Open a thread on one anchor, with its first message. A thread never exists empty, so `text` is required and may not be blank. A `release` anchor must name a numbered release of this flow: the server verifies it and derives the label, so `anchorLabel` is ignored for that type. For any other anchor type `anchorLabel` is a display snapshot of the anchor as it reads now, stored so a later rename leaves the thread readable instead of unlabeled, and defaults to the anchor key. Requires member role. + * Write release rationale + * @description Create or update the human rationale for one release of this flow. A null `humanText` clears it. The generated summary is machine-written and cannot be set through this route. The target must be a numbered release version of this flow, not an autosave revision. Requires member role. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** @example ver_a1b2c3d4 */ + versionId: string; + humanText: string | null; + }; + }; + }; + responses: { + /** @description The stored rationale */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UpsertVersionAnnotationResponse']; + }; + }; + /** @description Invalid body, or the target is not a release */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/flows/{flowId}/threads': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List discussion threads on a flow + * @description Threads anchored to things in this flow, most recently active first. `anchorType` and `anchorKey` narrow to one anchor and are only meaningful together. `includeMessages=true` attaches the messages; otherwise each thread carries `messageCount` alone. Attaching them holds the page to a smaller ceiling than the lean index and caps each thread at its newest 50 messages, with `hasMoreMessages` set when a thread holds more. Because that ceiling is below the `limit` a caller may pass, the response carries `hasMoreThreads`: a full page is not proof of a complete list. A resolved thread carries the release that settled it, and `resolvedByVersionId` is null once that release is gone, which is what `anchorLabel` is kept for. Requires member role. + */ + get: { + parameters: { + query?: { + anchorType?: + | 'step' + | 'entity_action' + | 'release' + | 'contract' + | 'tag'; + anchorKey?: string; + status?: 'open' | 'resolved'; + includeMessages?: 'true' | 'false'; + limit?: number; + }; + header?: never; + path: { + projectId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Threads on this flow */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListHubThreadsResponse']; + }; + }; + /** @description Invalid query */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + /** + * Open a discussion thread + * @description Open a thread on one anchor, with its first message. A thread never exists empty, so `text` is required and may not be blank. A `release` anchor must name a numbered release of this flow: the server verifies it and derives the label, so `anchorLabel` is ignored for that type. For any other anchor type `anchorLabel` is a display snapshot of the anchor as it reads now, stored so a later rename leaves the thread readable instead of unlabeled, and defaults to the anchor key. Requires member role. */ post: { parameters: { @@ -7199,7 +7277,7 @@ export interface paths { }; trace?: never; }; - '/api/projects/{projectId}/flows/{flowId}/releases/step-history': { + '/api/projects/{projectId}/knowledge': { parameters: { query?: never; header?: never; @@ -7207,35 +7285,36 @@ export interface paths { cookie?: never; }; /** - * List the releases that touched one step - * @description The releases of this flow that added, changed, or removed one step, newest first, each carrying the rationale stored for it. `step` is a `type.name` key over source, transformer, destination, store, and contract. `flow` narrows the scan to one named flow inside the config and is ignored for a contract key. `limit` bounds the releases scanned, not the entries returned. When nothing matched, `knownSteps` lists the addressable keys of the newest scanned release. Requires member role. + * List knowledge captured in this project + * @description What people wrote on the frames of a page, most recently active first. Two kinds come back together and `kind` separates them: a `thread` carries its text in messages, a `description` carries one body and cannot be replied to. `pageKey` narrows to a whole page, resolved server-side to every frame that page holds at any depth; `frameId` narrows to one frame; `markId` narrows to one mark within it and is refused without `frameId`, since a mark id alone addresses nothing. `includeMessages=true` attaches message bodies and holds the page to a much smaller ceiling, so `hasMoreEntries` is what separates a complete answer from a truncated one. `validity` says when an entry was true and `freshness` compares that against the flow’s newest release; neither is a verdict. Requires member role. */ get: { parameters: { - query: { - step: string; - flow?: string; - limit?: number | null; + query?: { + pageKey?: string; + frameId?: string; + markId?: string; + includeMessages?: 'true' | 'false'; + limit?: number; }; header?: never; path: { projectId: string; - flowId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The releases that touched the step */ + /** @description Knowledge in this project */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['StepHistoryResponse']; + 'application/json': components['schemas']['ListKnowledgeResponse']; }; }; - /** @description Invalid step key or query */ + /** @description Invalid query, or a mark filter with no frame */ 400: { headers: { [name: string]: unknown; @@ -7283,25 +7362,9 @@ export interface paths { }; }; put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/flows/{flowId}/releases/summarize': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Summarize or check a release - * @description Describe what one release of this flow changed against an earlier release, or check a written note against that same change. In `draft` mode the generated text is stored as the release's generated summary; in `check` mode nothing is stored and the response says whether the note matches. The diff is always recomputed from the two stored snapshots, with secret literals masked, and is never taken from the request. Both versions must be numbered releases of this flow, and `prevVersionId` must be the earlier of the two. Requires member role and the `hub` feature. + * Open a thread on a mark or a frame + * @description Open a thread on one mark of one frame, or on the frame itself with `anchorType` `page` and no `markId`, with its first message. A thread never exists empty, so `text` is required and may not be blank. `clientThreadId` and `clientMessageId` are minted by the client at compose time and are what make a replay idempotent: repeating a known `clientThreadId` hands back the existing thread and writes nothing, so an offline queue can drain repeatedly without duplicating what a person wrote once. `flowId` binds the capture to a flow or is explicitly null; a flow this project cannot see answers 404, never 403. A frame this project does not hold answers 404 with `FRAME_NOT_FOUND`, which a draining client waits on and retries, because the frame’s own write may not have landed yet. The server decides the composed anchor key, the born release, the author and the source: a client cannot assert any of them. Requires member role. */ post: { parameters: { @@ -7309,34 +7372,43 @@ export interface paths { header?: never; path: { projectId: string; - flowId: string; }; cookie?: never; }; requestBody?: { content: { 'application/json': { - /** @example ver_a1b2c3d4 */ - versionId: string; - /** @example ver_a1b2c3d4 */ - prevVersionId: string; - /** @enum {string} */ - mode: 'draft' | 'check'; - currentText?: string; + /** + * @example tag + * @enum {string} + */ + anchorType: 'tag' | 'page'; + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ + frameId: string; + markId?: string; + anchorLabel?: string; + flowId: string | null; + subjectKey?: string; + spatial?: components['schemas']['KnowledgeSpatial']; + /** @example ct_7f3a91 */ + clientThreadId: string; + text: string; + /** @example ct_7f3a91 */ + clientMessageId: string; }; }; }; responses: { - /** @description The generated summary or the check verdict */ - 200: { + /** @description The thread, with its first message */ + 201: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SummarizeReleaseResponse']; + 'application/json': components['schemas']['KnowledgeThreadResponse']; }; }; - /** @description Invalid body, a target is not a release, the pair is out of order, or no LLM provider is configured */ + /** @description Invalid body */ 400: { headers: { [name: string]: unknown; @@ -7363,7 +7435,7 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Not found */ + /** @description The named flow or frame is not in this project */ 404: { headers: { [name: string]: unknown; @@ -7381,15 +7453,6 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description The model call failed or returned no usable text */ - 502: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ErrorResponse']; - }; - }; }; }; delete?: never; @@ -7398,36 +7461,56 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/deployments/{deploymentId}/versions/current/content': { + '/api/projects/{projectId}/knowledge/{threadId}/messages': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get current deployed content - * @description Get the active deployed per-setting content for a deployment, used to diff changes since deploy. Content is masked and display-only; every field is null when there is no deployed baseline. Requires member role. + * Reply to a knowledge thread + * @description Append a message to a thread and get the whole thread back, so a surface renders the new exchange without a second read. `text` may not be blank: a message cannot be cleared, so empty is invalid rather than a way to erase one. Repeating a `clientMessageId` already on the thread appends nothing and leaves `updatedAt` alone, so a retrying drain never keeps bumping a thread to the top of every list. A description has no conversation and cannot be replied to; addressing one answers 404. Requires member role. */ - get: { + post: { parameters: { query?: never; header?: never; path: { projectId: string; - deploymentId: string; + /** @description Thread ID (thr_...) */ + threadId: string; }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Deployed content (or a null baseline) */ - 200: { - headers: { - [name: string]: unknown; - }; + requestBody?: { + content: { + 'application/json': { + text: string; + /** @example ct_7f3a91 */ + clientMessageId: string; + }; + }; + }; + responses: { + /** @description The thread, with the new message */ + 201: { + headers: { + [name: string]: unknown; + }; content: { - 'application/json': components['schemas']['DeployedContentResponse']; + 'application/json': components['schemas']['KnowledgeThreadResponse']; + }; + }; + /** @description Invalid body */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -7468,51 +7551,69 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/projects/{projectId}/deployments/{deploymentId}/heartbeats': { + '/api/projects/{projectId}/knowledge/description': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; /** - * List deployment heartbeats - * @description List heartbeat records for a deployment with optional from/to time-range filtering and pagination. Requires member role. + * Write the description of a mark or a frame + * @description Write the one description of one anchor, a mark or the frame itself, replacing whatever it said before. There is no id to mint: the anchor is the key, so a replayed write lands on the same row by construction, which is why this is a PUT. An empty `body` is refused rather than stored, so a drain that arrives with nothing to say can never erase what a person wrote. The response is 200 whether the description was opened or replaced. Requires member role. */ - get: { + put: { parameters: { - query?: { - /** @description ISO start of the time range. */ - from?: string; - /** @description ISO end of the time range. */ - to?: string; - limit?: number; - offset?: number | null; - }; + query?: never; header?: never; path: { projectId: string; - deploymentId: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': { + /** + * @example tag + * @enum {string} + */ + anchorType: 'tag' | 'page'; + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ + frameId: string; + markId?: string; + anchorLabel?: string; + flowId: string | null; + subjectKey?: string; + spatial?: components['schemas']['KnowledgeSpatial']; + body: string; + }; + }; + }; responses: { - /** @description Heartbeat history */ + /** @description The stored description */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListHeartbeatsResponse']; + 'application/json': components['schemas']['KnowledgeDescriptionResponse']; + }; + }; + /** @description Invalid body */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -7533,7 +7634,7 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Not found */ + /** @description The named flow or frame is not in this project */ 404: { headers: { [name: string]: unknown; @@ -7542,9 +7643,17 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; - put?: never; post?: never; delete?: never; options?: never; @@ -7552,38 +7661,49 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/deployments/{deploymentId}/rotate-ingest-token': { + '/api/projects/{projectId}/frames': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Rotate ingest token - * @description Rotate the ingest token for a deployment. Owner-only. No grace window: the previous token is immediately invalidated and the new token is returned once. + * List the frames of a page, or of the whole project + * @description A frame is a named rectangle with marks inside it, the spatial unit of a measurement plan. Naming a `pageKey` returns that page’s frames at any depth, marks and all, newest updated first: the walk starts at the page’s top-level frames and descends containment, so a child is reachable through its parent rather than by carrying a page of its own. Naming no page returns every live frame of the project WITHOUT its marks, which is what makes that read cheap enough to answer "what does this project have": the marks are the bulk of a frame and a listing never renders them. That lean read asks nothing about containment, so a frame whose parent cannot be resolved still appears. `include=marks` asks that project-wide read for the marks anyway, for a surface that spans pages and cannot fetch a page at a time; it is a second, heavier read of the same rows, taken after the lean list has already painted, and omitting it returns exactly the lean rows. It says nothing to the page read, which carries marks either way. Requires member role. */ - post: { + get: { parameters: { - query?: never; + query?: { + pageKey?: string; + include?: 'marks'; + }; header?: never; path: { projectId: string; - deploymentId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description New ingest token */ + /** @description The page’s frames with their marks, or the project’s frames without them */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RotateIngestTokenResponse']; + 'application/json': + | components['schemas']['FrameListResponse'] + | components['schemas']['FrameLeanListResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -7613,15 +7733,26 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/projects/{projectId}/deployments/{deploymentId}/usage': { + '/api/projects/{projectId}/frames/{frameId}': { parameters: { query?: never; header?: never; @@ -7629,34 +7760,32 @@ export interface paths { cookie?: never; }; /** - * Deployment usage - * @description Aggregate usage summary plus bucketed chart data for a deployment over the requested period. Requires member role. + * Read one frame + * @description One frame with its marks. A frame of another project reads back as nothing and answers 404, never 403, so this route cannot become an oracle for what exists elsewhere. A deleted frame is gone to every read. Requires member role. */ get: { parameters: { - query?: { - /** @description Time window for the usage summary. */ - period?: '1h' | '24h' | '7d' | '30d'; - }; + query?: never; header?: never; path: { projectId: string; - deploymentId: string; + /** @description Frame ID (frm_...) */ + frameId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Usage summary and chart buckets */ + /** @description The frame */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DeploymentUsageResponse']; + 'application/json': components['schemas']['Frame']; }; }; - /** @description Validation error */ + /** @description The path segment does not address a frame */ 400: { headers: { [name: string]: unknown; @@ -7692,46 +7821,58 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/flows/{flowId}/custom-domains': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; /** - * List custom domains - * @description List custom domains attached to any deployment of this flow. Requires member role and the customDomains feature. + * Create or replace one frame + * @description The path is the identity, so the body carries no id: a create is a write to an absent row at `baseVersion` 0 and everything else is a replace. `clientWriteId` is minted at compose time and is what makes a replayed drain exact: a write whose id already produced the stored version landed once and is answered with that version, writing nothing, so an offline queue drains repeatedly without turning one edit into two versions. A write against a version someone else has moved past answers 409 `FRAME_VERSION_CONFLICT` carrying the head, which is what lets a client raise keep-mine against load-theirs on the one frame that conflicted instead of dropping what a person drew. A name another live frame already holds is a distinct 409 `FRAME_NAME_EXISTS`. A relation naming a frame this project does not hold, or one that would place a frame inside itself, is 400 `INVALID_FRAME`. The screenshot is never touched here: a frame write carries no capture. Requires member role. */ - get: { + put: { parameters: { query?: never; header?: never; path: { projectId: string; - flowId: string; + /** @description Frame ID (frm_...) */ + frameId: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': { + frame: components['schemas']['FrameInput']; + baseVersion: number; + clientWriteId: string; + }; + }; + }; responses: { - /** @description Custom domains for the flow */ + /** @description The stored version */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListCustomDomainsResponse']; + 'application/json': components['schemas']['PutFrameResponse']; + }; + }; + /** @description Invalid body, a bad relation, or a path segment that addresses no frame */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -7752,39 +7893,63 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description A stale base version, carrying the head, or a name another live frame already holds. Only the version conflict carries `head`: a name clash needs no frame to resolve, since the client already knows the name it sent. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': + | components['schemas']['FrameConflictResponse'] + | components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; - put?: never; + post?: never; /** - * Attach custom domain - * @description Attach a custom domain to the flow's latest server deployment, or to an explicit deployment supplied in the body. Requires member role and the customDomains feature. + * Delete one frame + * @description Soft-delete the frame and, transitively, every variation of what this delete removes. Children are not variations and survive: each live frame under a removed one is re-parented to its nearest live ancestor in the same transaction, and one left with no live ancestor becomes top-level and inherits the page it hung under, so nothing is left unreachable. Those re-parents are server writes that bump their own versions, so a client still holding a pre-delete version meets a conflict carrying the new parent. A frame this project does not hold answers 404: a delete that removed nothing is not a delete that succeeded. Requires member role. */ - post: { + delete: { parameters: { query?: never; header?: never; path: { projectId: string; - flowId: string; + /** @description Frame ID (frm_...) */ + frameId: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateCustomDomainRequest']; - }; - }; + requestBody?: never; responses: { - /** @description Custom domain attached */ - 201: { + /** @description The frame is deleted */ + 204: { headers: { [name: string]: unknown; }; - content: { - 'application/json': components['schemas']['CustomDomain']; - }; + content?: never; }; - /** @description Validation error */ + /** @description The path segment does not address a frame */ 400: { headers: { [name: string]: unknown; @@ -7820,8 +7985,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Conflict */ - 409: { + /** @description Rate limited */ + 429: { headers: { [name: string]: unknown; }; @@ -7831,13 +7996,12 @@ export interface paths { }; }; }; - delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/projects/{projectId}/flows/{flowId}/custom-domains/{domainId}': { + '/api/projects/{projectId}/frames/{frameId}/screenshot': { parameters: { query?: never; header?: never; @@ -7846,30 +8010,47 @@ export interface paths { }; get?: never; put?: never; - post?: never; /** - * Detach custom domain - * @description Detach a custom domain from its deployment and remove the Scaleway record. Idempotent: a missing domain still returns 204. + * Store the capture of one frame + * @description Store one screenshot and set it on its frame. The image arrives as base64 rather than multipart, because the extension relay carries string bodies only. The server decides everything about the bytes: it decodes them, counts the DECODED length against a 4 MB cap, reads the type from the file’s own magic bytes, and hashes them, so nothing the client claims about size or type is consulted. Captures are deduplicated by content within a project: identical pixels resolve to one asset and one upload, and `reused` says whether that happened, which is the common answer rather than the rare one because re-capturing an unchanged frame produces identical bytes. A body past the cap is 413 `PAYLOAD_TOO_LARGE` and one that is not a PNG is 415 `UNSUPPORTED_MEDIA_TYPE`. The capture bumps no frame version: it is not an edit, so an upload never conflicts with the frame write the client queued beside it. Requires member role. */ - delete: { + post: { parameters: { query?: never; header?: never; path: { projectId: string; - flowId: string; - domainId: string; + /** @description Frame ID (frm_...) */ + frameId: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': { + imageBase64: string; + meta: components['schemas']['FrameScreenshotMeta']; + }; + }; + }; responses: { - /** @description Custom domain detached */ - 204: { + /** @description The asset the bytes resolved to, and whether it already existed */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ScreenshotUploadResponse']; + }; + }; + /** @description Invalid body, or a path segment that addresses no frame */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; }; /** @description Unauthorized */ 401: { @@ -7889,48 +8070,17 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy-token': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Self-hosted deploy-token status - * @description Report whether a self-hosted deploy token exists for this config, plus the deployment health summary when present. Requires member role. - */ - get: { - parameters: { - query?: never; - header?: never; - path: { - projectId: string; - flowId: string; - settingsId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Deploy-token status */ - 200: { + /** @description This project does not hold the named frame */ + 404: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DeployTokenStatusResponse']; + 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Unauthorized */ - 401: { + /** @description The decoded image is past the 4 MB cap */ + 413: { headers: { [name: string]: unknown; }; @@ -7938,8 +8088,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Forbidden */ - 403: { + /** @description The bytes are not a PNG */ + 415: { headers: { [name: string]: unknown; }; @@ -7947,8 +8097,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Not found */ - 404: { + /** @description Rate limited */ + 429: { headers: { [name: string]: unknown; }; @@ -7958,35 +8108,47 @@ export interface paths { }; }; }; - put?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/assets/{assetId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * Mint self-hosted deploy token - * @description Create a self-hosted deployment (if none exists) and mint a flow+deployment-bound runner token. Admin-only. The raw token is returned once and never stored in plaintext. + * Read one stored capture + * @description The bytes of one frame screenshot, for the app canvas. The extension keeps its own capture locally and never reads assets back. Same-origin and session-authenticated: the response carries `Cross-Origin-Resource-Policy: same-origin`, so no other site can embed a tenant capture off the reader’s session. The bytes are immutable by construction, since the object key is their own content hash, which is why they are cacheable for a year, and `private` keeps a shared cache from serving one tenant’s capture to the next request for the same URL. An asset another project holds answers 404, never 403. Requires member role. */ - post: { + get: { parameters: { query?: never; header?: never; path: { projectId: string; - flowId: string; - settingsId: string; + /** @description Asset ID (fas_...) */ + assetId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Deploy token minted */ - 201: { + /** @description The image bytes */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateDeployTokenResponse']; + 'image/png': string; }; }; - /** @description Unauthorized */ - 401: { + /** @description The path segment does not address an asset */ + 400: { headers: { [name: string]: unknown; }; @@ -7994,8 +8156,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Forbidden */ - 403: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -8003,8 +8165,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Not found */ - 404: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -8012,47 +8174,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/entitlements': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Resolved entitlements - * @description Return resolved feature entitlements for the authenticated user and project. Used by CLI/API clients; the web UI uses SSR-resolved entitlements. Requires viewer role. - */ - get: { - parameters: { - query?: never; - header?: never; - path: { - projectId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Resolved entitlements */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['EntitlementsResponse']; - }; - }; - /** @description Unauthorized */ - 401: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8060,8 +8183,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Forbidden */ - 403: { + /** @description Rate limited */ + 429: { headers: { [name: string]: unknown; }; @@ -8079,7 +8202,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/settings/llm': { + '/api/projects/{projectId}/canvases': { parameters: { query?: never; header?: never; @@ -8087,8 +8210,8 @@ export interface paths { cookie?: never; }; /** - * Active LLM provider - * @description Report which LLM provider is currently active for the project and where billing is sourced. Never returns the apiKey. Requires member role and the chat feature. + * List the canvases of the project + * @description A canvas is a named, freely arranged board over a project’s frames, the surface on which a plan is laid out across pages rather than within one. This returns every live canvas by name WITHOUT its document: the document is the bulk of a canvas and a listing renders none of it, so opening a board is the single-canvas read. Requires member role. */ get: { parameters: { @@ -8101,13 +8224,13 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Active LLM provider status */ + /** @description The project’s canvases, without their documents */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LlmConfigStatusResponse']; + 'application/json': components['schemas']['CanvasListResponse']; }; }; /** @description Unauthorized */ @@ -8128,21 +8251,30 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description No platform LLM provider configured */ - 503: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LlmConfigStatusResponse']; + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; }; }; put?: never; /** - * Set LLM provider - * @description Set or clear the project LLM provider override. Admin-only, gated by the chat feature. The apiKey is write-only: it is encrypted and never returned. + * Create one canvas + * @description Create one empty canvas at version 1. The id is the client’s, so a board drawn before the first save keeps its identity when it arrives. A canvas comes into existence here and nowhere else: a document write to an id the project does not hold is a 404 rather than a create, which is what keeps a stray write from minting a board. A name another live canvas already holds is 409 `CANVAS_NAME_EXISTS`; the partial unique index is over live rows, so a name a deleted canvas still carries is free. An id that is not a canvas id is refused by the body schema as 400 `VALIDATION_ERROR`. An id that is not available, because a canvas, in this project or another, already holds it, is 400 `INVALID_CANVAS`, whose message says nothing about the project that holds it. Requires member role. */ post: { parameters: { @@ -8155,20 +8287,24 @@ export interface paths { }; requestBody?: { content: { - 'application/json': components['schemas']['SetLlmConfigRequest']; + 'application/json': { + /** @example cnv_V1StGXR8Z5jdHi6BmyT7K */ + id: string; + name: string; + }; }; }; responses: { - /** @description LLM config saved or cleared */ - 200: { + /** @description The created canvas, with its empty document */ + 201: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['SetLlmConfigResponse']; + 'application/json': components['schemas']['Canvas']; }; }; - /** @description Validation error */ + /** @description Invalid body, or an id that is not available */ 400: { headers: { [name: string]: unknown; @@ -8204,50 +8340,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/chat/sessions': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List chat sessions - * @description List the caller's recent chat sessions for a project, ordered by last activity. Requires member role and the chat feature. - */ - get: { - parameters: { - query?: { - limit?: number; - offset?: number | null; - }; - header?: never; - path: { - projectId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Chat session list */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ListChatSessionsResponse']; - }; - }; - /** @description Unauthorized */ - 401: { + /** @description A name another live canvas already holds */ + 409: { headers: { [name: string]: unknown; }; @@ -8255,8 +8349,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Forbidden */ - 403: { + /** @description Rate limited */ + 429: { headers: { [name: string]: unknown; }; @@ -8266,15 +8360,13 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/projects/{projectId}/chat/sessions/{sessionId}': { + '/api/projects/{projectId}/canvases/{canvasId}': { parameters: { query?: never; header?: never; @@ -8282,8 +8374,8 @@ export interface paths { cookie?: never; }; /** - * Get chat session - * @description Return a chat session and its full message history when the caller owns it. Foreign or unknown sessions return 404 (never 403) so existence is not leaked. Requires member role and the chat feature. + * Read one canvas + * @description One canvas with its whole document: the nodes with their positions, the edges, and the node keys the board suppresses. A canvas of another project reads back as nothing and answers 404, never 403, so this route cannot become an oracle for what exists elsewhere. A deleted canvas is gone to every read. Requires member role. */ get: { parameters: { @@ -8291,19 +8383,29 @@ export interface paths { header?: never; path: { projectId: string; - sessionId: string; + /** @description Canvas ID (cnv_...) */ + canvasId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Chat session with messages */ + /** @description The canvas */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ChatSessionDetailResponse']; + 'application/json': components['schemas']['Canvas']; + }; + }; + /** @description The path segment does not address a canvas */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -8333,54 +8435,52 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{projectId}/chat/elicit': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; /** - * Answer elicitation prompt - * @description Answer a pending MCP elicitation prompt (accept, decline, or cancel), unblocking the waiting tool invocation. Requires member role and the chat feature. + * Replace the document of one canvas + * @description The whole board every time: a canvas is read and written as a unit, so there is no partial write to reconcile. `clientWriteId` is minted at compose time and is what makes a replayed drain exact: a write whose id already produced the stored version landed once and is answered with that version, writing nothing, so an offline queue drains repeatedly without turning one edit into two versions. A write against a version someone else has moved past answers 409 `CANVAS_VERSION_CONFLICT` carrying the head, which is what lets a client raise keep-mine against load-theirs on the board that conflicted instead of dropping what a person drew. A canvas this project does not hold, or one that was removed, is 404 `CANVAS_NOT_FOUND`: this door replaces a document and never creates one. Requires member role. */ - post: { + put: { parameters: { query?: never; header?: never; path: { projectId: string; + /** @description Canvas ID (cnv_...) */ + canvasId: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['ElicitRequest']; + 'application/json': { + document: components['schemas']['CanvasDocument']; + baseVersion: number; + clientWriteId: string; + }; }; }; responses: { - /** @description Elicitation resolved */ + /** @description The stored version */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ElicitResponse']; + 'application/json': components['schemas']['PutCanvasResponse']; }; }; - /** @description Validation error */ + /** @description Invalid body, or a path segment that addresses no canvas */ 400: { headers: { [name: string]: unknown; @@ -8407,7 +8507,7 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Not found */ + /** @description This project does not hold the named canvas */ 404: { headers: { [name: string]: unknown; @@ -8416,41 +8516,76 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/mcp/tokens': { - parameters: { + /** @description A stale base version, carrying the head */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CanvasConflictResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/flows/{flowId}/releases/step-history': { + parameters: { query?: never; header?: never; path?: never; cookie?: never; }; /** - * List MCP tokens - * @description List the authenticated user's personal MCP tokens. No secret material is returned. + * List the releases that touched one step + * @description The releases of this flow that added, changed, or removed one step, newest first, each carrying the rationale stored for it. `step` is a `type.name` key over source, transformer, destination, store, and contract. `flow` narrows the scan to one named flow inside the config and is ignored for a contract key. `limit` bounds the releases scanned, not the entries returned. When nothing matched, `knownSteps` lists the addressable keys of the newest scanned release. Requires member role. */ get: { parameters: { - query?: never; + query: { + step: string; + flow?: string; + limit?: number | null; + }; header?: never; - path?: never; + path: { + projectId: string; + flowId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description MCP token list */ + /** @description The releases that touched the step */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListMcpTokensResponse']; + 'application/json': components['schemas']['StepHistoryResponse']; + }; + }; + /** @description Invalid step key or query */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; }; }; /** @description Unauthorized */ @@ -8462,36 +8597,90 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/flows/{flowId}/releases/summarize': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; /** - * Issue MCP token - * @description Issue a personal MCP token. The raw token is returned exactly once and is never retrievable afterwards. + * Summarize or check a release + * @description Describe what one release of this flow changed against an earlier release, or check a written note against that same change. In `draft` mode the generated text is stored as the release's generated summary; in `check` mode nothing is stored and the response says whether the note matches. The diff is always recomputed from the two stored snapshots, with secret literals masked, and is never taken from the request. Both versions must be numbered releases of this flow, and `prevVersionId` must be the earlier of the two. Requires member role and the `hub` feature. */ post: { parameters: { query?: never; header?: never; - path?: never; + path: { + projectId: string; + flowId: string; + }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMcpTokenRequest']; + 'application/json': { + /** @example ver_a1b2c3d4 */ + versionId: string; + /** @example ver_a1b2c3d4 */ + prevVersionId: string; + /** @enum {string} */ + mode: 'draft' | 'check'; + currentText?: string; + }; }; }; responses: { - /** @description MCP token issued */ - 201: { + /** @description The generated summary or the check verdict */ + 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateMcpTokenResponse']; + 'application/json': components['schemas']['SummarizeReleaseResponse']; }; }; - /** @description Validation error */ + /** @description Invalid body, a target is not a release, the pair is out of order, or no LLM provider is configured */ 400: { headers: { [name: string]: unknown; @@ -8509,6 +8698,42 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description The model call failed or returned no usable text */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; delete?: never; @@ -8517,37 +8742,37 @@ export interface paths { patch?: never; trace?: never; }; - '/api/mcp/tokens/{tokenId}': { + '/api/projects/{projectId}/deployments/{deploymentId}/versions/current/content': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post?: never; /** - * Revoke MCP token - * @description Revoke a personal MCP token by id. + * Get current deployed content + * @description Get the active deployed per-setting content for a deployment, used to diff changes since deploy. Content is masked and display-only; every field is null when there is no deployed baseline. Requires member role. */ - delete: { + get: { parameters: { query?: never; header?: never; path: { - tokenId: string; + projectId: string; + deploymentId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description MCP token revoked */ - 204: { + /** @description Deployed content (or a null baseline) */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['DeployedContentResponse']; + }; }; /** @description Unauthorized */ 401: { @@ -8558,14 +8783,44 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/projects/{projectId}/runners': { + '/api/projects/{projectId}/deployments/{deploymentId}/heartbeats': { parameters: { query?: never; header?: never; @@ -8573,27 +8828,35 @@ export interface paths { cookie?: never; }; /** - * List runners (deprecated) - * @description Deprecated: runners migrated to deployments (origin=self-hosted). Always returns an empty list for backward compatibility. Requires member role. + * List deployment heartbeats + * @description List heartbeat records for a deployment with optional from/to time-range filtering and pagination. Requires member role. */ get: { parameters: { - query?: never; + query?: { + /** @description ISO start of the time range. */ + from?: string; + /** @description ISO end of the time range. */ + to?: string; + limit?: number; + offset?: number | null; + }; header?: never; path: { projectId: string; + deploymentId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Empty runner list */ + /** @description Heartbeat history */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ListRunnersResponse']; + 'application/json': components['schemas']['ListHeartbeatsResponse']; }; }; /** @description Unauthorized */ @@ -8614,6 +8877,15 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; put?: never; @@ -8624,7 +8896,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/projects/{projectId}/runners/heartbeat': { + '/api/projects/{projectId}/deployments/{deploymentId}/rotate-ingest-token': { parameters: { query?: never; header?: never; @@ -8634,8 +8906,8 @@ export interface paths { get?: never; put?: never; /** - * Runner heartbeat - * @description Accept a self-hosted runner heartbeat with usage counters. Authenticated by a flow+deployment-bound runner token. Updates deployment liveness and records an immutable usage row. + * Rotate ingest token + * @description Rotate the ingest token for a deployment. Owner-only. No grace window: the previous token is immediately invalidated and the new token is returned once. */ post: { parameters: { @@ -8643,26 +8915,23 @@ export interface paths { header?: never; path: { projectId: string; + deploymentId: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['HeartbeatRequest']; - }; - }; + requestBody?: never; responses: { - /** @description Heartbeat accepted */ + /** @description New ingest token */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['RunnerHeartbeatResponse']; + 'application/json': components['schemas']['RotateIngestTokenResponse']; }; }; - /** @description Validation error */ - 400: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -8670,8 +8939,8 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Unauthorized */ - 401: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -8696,7 +8965,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/packages': { + '/api/projects/{projectId}/deployments/{deploymentId}/usage': { parameters: { query?: never; header?: never; @@ -8704,30 +8973,31 @@ export interface paths { cookie?: never; }; /** - * Package catalog - * @description Resolved `@walkeros/*` package catalog for the add-step picker, optionally filtered by type and platform. + * Deployment usage + * @description Aggregate usage summary plus bucketed chart data for a deployment over the requested period. Requires member role. */ get: { parameters: { query?: { - /** @description Filter by package type. */ - type?: string; - /** @description Filter by platform. */ - platform?: string; + /** @description Time window for the usage summary. */ + period?: '1h' | '24h' | '7d' | '30d'; }; header?: never; - path?: never; + path: { + projectId: string; + deploymentId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Package catalog */ + /** @description Usage summary and chart buckets */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PackageCatalogResponse']; + 'application/json': components['schemas']['DeploymentUsageResponse']; }; }; /** @description Validation error */ @@ -8739,8 +9009,26 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; - /** @description Package catalog unavailable */ - 502: { + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8758,7 +9046,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/packages/search': { + '/api/projects/{projectId}/flows/{flowId}/custom-domains': { parameters: { query?: never; header?: never; @@ -8766,29 +9054,41 @@ export interface paths { cookie?: never; }; /** - * Search packages - * @description Returns the full @walkeros/* package catalog; clients filter locally. + * List custom domains + * @description List custom domains attached to any deployment of this flow. Requires member role and the customDomains feature. */ get: { parameters: { query?: never; header?: never; - path?: never; + path: { + projectId: string; + flowId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Search results */ + /** @description Custom domains for the flow */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PackageSearchResponse']; + 'application/json': components['schemas']['ListCustomDomainsResponse']; }; }; - /** @description Package search unavailable */ - 502: { + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -8800,28 +9100,33 @@ export interface paths { }; put?: never; /** - * Log a settled search - * @description Records one settled search outcome (the term the user paused on and whether the catalog matched it). Fire-and-forget; returns 204. + * Attach custom domain + * @description Attach a custom domain to the flow's latest server deployment, or to an explicit deployment supplied in the body. Requires member role and the customDomains feature. */ post: { parameters: { query?: never; header?: never; - path?: never; + path: { + projectId: string; + flowId: string; + }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['PackageSearchLogRequest']; + 'application/json': components['schemas']['CreateCustomDomainRequest']; }; }; responses: { - /** @description Search logged */ - 204: { + /** @description Custom domain attached */ + 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['CustomDomain']; + }; }; /** @description Validation error */ 400: { @@ -8832,6 +9137,42 @@ export interface paths { 'application/json': components['schemas']['ErrorResponse']; }; }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; }; }; delete?: never; @@ -8840,7 +9181,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/observe/timing': { + '/api/projects/{projectId}/flows/{flowId}/custom-domains/{domainId}': { parameters: { query?: never; header?: never; @@ -8849,32 +9190,42 @@ export interface paths { }; get?: never; put?: never; + post?: never; /** - * Report connect timing - * @description Fire-and-forget beacon for client-side connect timing SLIs. No auth required; carries no secrets. Returns 204. + * Detach custom domain + * @description Detach a custom domain from its deployment and remove the Scaleway record. Idempotent: a missing domain still returns 204. */ - post: { + delete: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['ObserveTimingRequest']; + path: { + projectId: string; + flowId: string; + domainId: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Timing recorded */ + /** @description Custom domain detached */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Validation error */ - 400: { + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -8884,51 +9235,1588 @@ export interface paths { }; }; }; - delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { - ErrorResponse: { - error: { - /** @example VALIDATION_ERROR */ - code: string; - /** @example Validation failed */ - message: string; - details?: { - field?: string; - reason?: string; - errors?: { - path: string; - message: string; - }[]; - } & { - [key: string]: unknown; + '/api/projects/{projectId}/flows/{flowId}/settings/{settingsId}/deploy-token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Self-hosted deploy-token status + * @description Report whether a self-hosted deploy token exists for this config, plus the deployment health summary when present. Requires member role. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + flowId: string; + settingsId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deploy-token status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeployTokenStatusResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; }; }; }; - ClientOutdatedError: { - error: { - /** @enum {string} */ - code: 'CLIENT_OUTDATED'; - /** @example This endpoint requires @walkeros/cli >= 3.5.0 (you are on 3.3.1). */ - message: string; - /** @example 3.5.0 */ - minVersion: string; - /** @example 3.3.1 */ - clientVersion: string; - /** @example cli */ - client: string; - /** @example npm install -g @walkeros/cli@latest */ - upgrade: string; - /** - * Format: uri - * @example https://walkeros.io/docs/upgrading + put?: never; + /** + * Mint self-hosted deploy token + * @description Create a self-hosted deployment (if none exists) and mint a flow+deployment-bound runner token. Admin-only. The raw token is returned once and never stored in plaintext. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + flowId: string; + settingsId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deploy token minted */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CreateDeployTokenResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/entitlements': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Resolved entitlements + * @description Return resolved feature entitlements for the authenticated user and project. Used by CLI/API clients; the web UI uses SSR-resolved entitlements. Requires viewer role. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resolved entitlements */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EntitlementsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/settings/llm': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Active LLM provider + * @description Report which LLM provider is currently active for the project and where billing is sourced. Never returns the apiKey. Requires member role and the chat feature. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active LLM provider status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LlmConfigStatusResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description No platform LLM provider configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LlmConfigStatusResponse']; + }; + }; + }; + }; + put?: never; + /** + * Set LLM provider + * @description Set or clear the project LLM provider override. Admin-only, gated by the chat feature. The apiKey is write-only: it is encrypted and never returned. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['SetLlmConfigRequest']; + }; + }; + responses: { + /** @description LLM config saved or cleared */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SetLlmConfigResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/chat/sessions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List chat sessions + * @description List the caller's recent chat sessions for a project, ordered by last activity. Requires member role and the chat feature. + */ + get: { + parameters: { + query?: { + limit?: number; + offset?: number | null; + }; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Chat session list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListChatSessionsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/chat/sessions/{sessionId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get chat session + * @description Return a chat session and its full message history when the caller owns it. Foreign or unknown sessions return 404 (never 403) so existence is not leaked. Requires member role and the chat feature. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Chat session with messages */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ChatSessionDetailResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/chat/elicit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Answer elicitation prompt + * @description Answer a pending MCP elicitation prompt (accept, decline, or cancel), unblocking the waiting tool invocation. Requires member role and the chat feature. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['ElicitRequest']; + }; + }; + responses: { + /** @description Elicitation resolved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ElicitResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/runners': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List runners (deprecated) + * @description Deprecated: runners migrated to deployments (origin=self-hosted). Always returns an empty list for backward compatibility. Requires member role. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Empty runner list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListRunnersResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/projects/{projectId}/runners/heartbeat': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Runner heartbeat + * @description Accept a self-hosted runner heartbeat with usage counters. Authenticated by a flow+deployment-bound runner token. Updates deployment liveness and records an immutable usage row. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + projectId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['HeartbeatRequest']; + }; + }; + responses: { + /** @description Heartbeat accepted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RunnerHeartbeatResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/packages': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Package catalog + * @description Resolved `@walkeros/*` package catalog for the add-step picker, optionally filtered by type and platform. + */ + get: { + parameters: { + query?: { + /** @description Filter by package type. */ + type?: string; + /** @description Filter by platform. */ + platform?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Package catalog */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PackageCatalogResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Package catalog unavailable */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/packages/search': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search packages + * @description Returns the full @walkeros/* package catalog; clients filter locally. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PackageSearchResponse']; + }; + }; + /** @description Package search unavailable */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + /** + * Log a settled search + * @description Records one settled search outcome (the term the user paused on and whether the catalog matched it). Fire-and-forget; returns 204. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['PackageSearchLogRequest']; + }; + }; + responses: { + /** @description Search logged */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/observe/timing': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Report connect timing + * @description Fire-and-forget beacon for client-side connect timing SLIs. No auth required; carries no secrets. Returns 204. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['ObserveTimingRequest']; + }; + }; + responses: { + /** @description Timing recorded */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/register': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register a client + * @description RFC 7591 dynamic client registration. Unauthenticated: a client registers itself before it holds any credential. Issues public clients only (`token_endpoint_auth_method: none`), which prove themselves with PKCE. Errors use the RFC 7591 section 3.2.2 shape, not the standard error envelope. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['OAuthClientRegistrationRequest']; + }; + }; + responses: { + /** @description Client registered */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthClientRegistrationResponse']; + }; + }; + /** @description Invalid client metadata or redirect URI */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthRegistrationError']; + }; + }; + /** @description Registration ceiling reached (Retry-After header) */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/device_authorization': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start a device authorization + * @description RFC 8628 section 3.1. A client that cannot host a browser redirect asks for a device code and a user code here, then polls the token endpoint while the person approves the user code at `/oauth/device`. Unauthenticated, and public clients only: the code is worth nothing until a signed-in person approves it. Body is `application/x-www-form-urlencoded`; errors use the RFC 6749 section 5.2 shape, not the standard error envelope. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['DeviceAuthorizationRequest']; + }; + }; + responses: { + /** @description Device authorization opened */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeviceAuthorizationResponse']; + }; + }; + /** @description invalid_request, unauthorized_client, invalid_scope or invalid_target */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + /** @description invalid_client: unknown, revoked or confidential client */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a grant for tokens + * @description RFC 6749 section 3.2. Runs the authorization code, refresh token and device code grants. The client authenticates here: a public client with PKCE, a confidential one with HTTP Basic or a form secret. Body is `application/x-www-form-urlencoded` only; errors use the RFC 6749 section 5.2 shape, not the standard error envelope, and a failed Basic authentication is answered with a `WWW-Authenticate: Basic` challenge. Responses are never cacheable. Rate limited per `client_id`. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['TokenRequest']; + }; + }; + responses: { + /** @description Tokens issued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TokenResponse']; + }; + }; + /** @description invalid_request, invalid_grant, invalid_scope, invalid_target, unsupported_grant_type, or a device grant status (authorization_pending, slow_down, access_denied, expired_token) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + /** @description invalid_client: unknown, revoked, or bad credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + /** @description Per-client token budget reached (Retry-After header) */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/revoke': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke a token + * @description RFC 7009. Client authentication is the same as at the token endpoint. A refresh token revokes its whole rotation family, an access token only itself. An authenticated request always answers 200 with an empty body, unknown tokens included: a distinguishable answer would be an oracle. Body is `application/x-www-form-urlencoded`. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/x-www-form-urlencoded': components['schemas']['RevocationRequest']; + }; + }; + responses: { + /** @description Revoked, or nothing matched */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_request or unsupported_token_type */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + /** @description invalid_client: unknown, revoked, or bad credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthError']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/device/approve': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Decide a device authorization + * @description The person's approve or deny decision on a pending device authorization. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token can never approve its own device. Requires the `X-CSRF-Token` minted with the consent page, bound to this user code. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['DeviceApprovalRequest']; + }; + }; + responses: { + /** @description Decision recorded */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeviceApprovalResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/authorize': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Decide a consent request + * @description The person's allow or deny decision on the consent screen at `/oauth/authorize`. The ticket is the HMAC-signed authorization request that screen was rendered from, so the decision cannot alter what was validated, and it is bound to the person it was minted for. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token can never approve a consent. The response says where to send the browser: the client's registered redirect URI, carrying `code` on allow and `error=access_denied` on deny. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['OAuthConsentDecisionRequest']; + }; + }; + responses: { + /** @description Decision recorded */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OAuthConsentDecisionResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/grants': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List connected apps + * @description The apps the signed-in person has consented to, as the Connected apps page renders them. Revoked grants are absent. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a machine token cannot read the connections its owner holds. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Connected apps */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListOAuthGrantsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + /** + * Disconnect every app + * @description Revoke every grant this person holds and the tokens hanging from them. Automation tokens hang from no grant and survive. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`, so a read-scoped machine token cannot disconnect everything its owner has connected. + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Apps disconnected */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/oauth/grants/{grantId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Disconnect one app + * @description Revoke one grant and the tokens hanging from it. Idempotent: an unknown grant, another person's grant and an already revoked one all answer 204, and the token sweep runs either way, so pressing Disconnect twice cleans up a token minted inside the first press's window. Session only: a bearer credential is refused with 401 `SESSION_REQUIRED`. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + grantId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description App disconnected */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/oauth/clients': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List OAuth clients + * @description Every registered OAuth client, revoked ones included. No secret material is returned. Admin only: a non-admin caller gets 404, not 403, so the endpoint does not confirm its own existence. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OAuth client list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListOAuthClientsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + put?: never; + /** + * Create a confidential OAuth client + * @description Create an OAuth client that authenticates with a secret. The raw secret is returned exactly once and is never retrievable afterwards. Admin only: a non-admin caller gets 404, not 403. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['CreateOAuthClientRequest']; + }; + }; + responses: { + /** @description Client created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CreateOAuthClientResponse']; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/oauth/clients/{clientId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke an OAuth client + * @description Revoke a client together with the grants consented to it and the tokens minted under them. Admin only: a non-admin caller gets 404, not 403, the same answer an unknown client id gets. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + clientId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Client revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponse']; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ErrorResponse: { + error: { + /** @example VALIDATION_ERROR */ + code: string; + /** @example Validation failed */ + message: string; + details?: { + field?: string; + reason?: string; + errors?: { + path: string; + message: string; + }[]; + } & { + [key: string]: unknown; + }; + }; + }; + ClientOutdatedError: { + error: { + /** @enum {string} */ + code: 'CLIENT_OUTDATED'; + /** @example This endpoint requires @walkeros/cli >= 3.5.0 (you are on 3.3.1). */ + message: string; + /** @example 3.5.0 */ + minVersion: string; + /** @example 3.3.1 */ + clientVersion: string; + /** @example cli */ + client: string; + /** @example npm install -g @walkeros/cli@latest */ + upgrade: string; + /** + * Format: uri + * @example https://walkeros.io/docs/upgrading */ docs: string; }; @@ -8947,318 +10835,829 @@ export interface components { flows?: { [key: string]: unknown; }; - contract?: { + contract?: { + [key: string]: unknown; + }; + } & { + [key: string]: unknown; + }; + Flow: { + /** @example flow_a1b2c3d4 */ + id: string; + /** @example my-website-flow */ + name: string; + config: components['schemas']['FlowConfig']; + settings?: components['schemas']['FlowSettingsSummary'][]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** Format: date-time */ + deletedAt?: string | null; + }; + FlowSettingsSummary: { + /** @example cfg_a1b2c3d4 */ + id: string; + name: string; + /** + * @example web + * @enum {string} + */ + platform: 'web' | 'server'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + }; + FlowSummary: { + /** @example flow_a1b2c3d4 */ + id: string; + /** @example my-website-flow */ + name: string; + summary?: string; + settings?: components['schemas']['FlowSettingsListItem'][]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** Format: date-time */ + deletedAt: string | null; + }; + FlowSettingsListItem: { + /** @example cfg_a1b2c3d4 */ + id: string; + name: string; + /** + * @example web + * @enum {string} + */ + platform: 'web' | 'server'; + serving: components['schemas']['ServingStatus']; + latestAttempt: components['schemas']['LatestAttemptStatus']; + deploymentUrl: string | null; + deployedAt: string | null; + }; + /** @enum {string} */ + ServingStatus: 'live' | 'none'; + /** @enum {string|null} */ + LatestAttemptStatus: + | 'idle' + | 'deploying' + | 'published' + | 'active' + | 'stopped' + | 'failed' + | null; + Version: { + /** @example 1 */ + version: number; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * @example user + * @enum {string} + */ + createdBy: 'user' | 'auto_save' | 'restore' | 'deploy' | 'preview'; + /** @example sha256:abc123... */ + contentHash?: string; + }; + Project: { + /** @example proj_x7y8z9 */ + id: string; + /** @example My Website */ + name: string; + /** + * @example owner + * @enum {string} + */ + role: 'owner' | 'admin' | 'member' | 'deployer' | 'viewer'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** @example 3 */ + memberCount: number; + /** @example 5 */ + flowCount: number; + /** @example 2 */ + deploymentCount: number; + /** @example false */ + isDemo: boolean; + }; + Member: { + userId: string; + /** Format: email */ + email: string; + /** @enum {string} */ + role: 'owner' | 'admin' | 'member' | 'deployer' | 'viewer'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + }; + DeleteAccountRequest: { + /** @example me@example.com */ + confirm: string; + }; + DeleteAccountBlocked: { + error: { + /** + * @example SOLE_OWNER + * @enum {string} + */ + code: 'SOLE_OWNER'; + message: string; + details: { + /** + * @example [ + * "proj_abc123" + * ] + */ + projects: string[]; + }; + }; + }; + AccountExportResponse: { + /** @example 2026-06-10T12:00:00.000Z */ + exportedAt: string; + profile: { + /** @example user_a1b2c3d4 */ + id: string; + /** @example me@example.com */ + email: string; + displayName: string | null; + createdAt: string; + lastLoginAt: string | null; + /** @example user */ + globalRole: string; + traits: string[]; + }; + memberships: { + projectId: string; + projectName: string; + role: string; + joinedAt: string; + }[]; + tokens: { + id: string; + name: string; + /** @example automation */ + kind: string; + /** @example read write */ + scope: string; + /** @example api mcp */ + audience: string; + projectId: string | null; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string; + revokedAt: string | null; + }[]; + sessions: { + id: string; + createdAt: string; + expiresAt: string; + lastTouchedAt: string; + }[]; + mcpSessions: { + id: string; + projectId: string | null; + createdAt: string; + lastActiveAt: string; + expiresAt: string; + messages: { + seq: number; + role: string; + content?: unknown; + createdAt: string; + }[]; + }[]; + feedback: { + id: string; + projectId: string | null; + text: string; + source: string; + createdAt: string; + }[]; + invitations: { + id: string; + projectId: string; + invitedEmail: string; + role: string; + status: string; + createdAt: string; + expiresAt: string; + acceptedAt: string | null; + declinedAt: string | null; + cancelledAt: string | null; + }[]; + }; + AutomationTokenSummary: { + /** @example tok_a1b2c3d4 */ + id: string; + /** @example CI Pipeline */ + name: string; + /** @example wos_pat_a1b2 */ + tokenPrefix: string; + /** + * @example [ + * "read", + * "write" + * ] + */ + scope: string[]; + /** + * @example [ + * "api", + * "mcp" + * ] + */ + audience: string[]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + lastUsedAt: string | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + expiresAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + revokedAt: string | null; + }; + FlowSettingsDetail: { + /** @example cfg_a1b2c3d4 */ + id: string; + name: string; + /** + * @example web + * @enum {string} + */ + platform: 'web' | 'server'; + config: { [key: string]: unknown; }; - } & { - [key: string]: unknown; + deployment?: { + id: string; + status: string; + type: string; + containerUrl?: string | null; + publicUrl?: string | null; + errorMessage?: string | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + } | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; }; - Flow: { + DeploySettingsRequest: { + flow?: string; + humanText?: string; + }; + DeploySettingsResponse: { + deploymentId: string; + /** @example cfg_a1b2c3d4 */ + settingsId: string; + status: string; + }; + FlowDetailResponse: { /** @example flow_a1b2c3d4 */ id: string; /** @example my-website-flow */ name: string; config: components['schemas']['FlowConfig']; - settings?: components['schemas']['FlowSettingsSummary'][]; + settings?: components['schemas']['FlowSettingsEnriched'][]; + bundleId?: string | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** Format: date-time */ + deletedAt: string | null; + }; + FlowSettingsEnriched: { + id: string; + name: string; + /** @enum {string} */ + platform: 'web' | 'server'; + deployment: { + id: string; + slug: string; + status: string; + type: string; + target: string | null; + containerUrl: string | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + updatedAt: string | null; + } | null; + serving: components['schemas']['ServingStatus']; + latestAttempt: components['schemas']['LatestAttemptStatus']; + deployedAt: string | null; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + }; + FlowUpdateResponse: { + /** @example flow_a1b2c3d4 */ + id: string; + /** @example my-website-flow */ + name: string; + config: components['schemas']['FlowConfig']; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + }; + CreateProjectResponse: { + /** @example proj_x7y8z9 */ + id: string; + name: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + }; + UpdateProjectResponse: { + /** @example proj_x7y8z9 */ + id: string; + name: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ + updatedAt: string; + }; + DeploymentSummary: { + /** @example dep_a1b2c3d4 */ + id: string; + /** + * @example web + * @enum {string} + */ + type: 'web' | 'server'; + /** @example k7m2x9p4q1w8 */ + slug: string; + target: string | null; + label: string | null; + /** + * @example cloud + * @enum {string} + */ + origin: 'cloud' | 'self-hosted'; + /** + * @example active + * @enum {string} + */ + status: + | 'idle' + | 'deploying' + | 'published' + | 'active' + | 'stopped' + | 'failed'; + serving: components['schemas']['ServingStatus']; + currentVersionNumber: number | null; + url: string | null; + /** @example flow_a1b2c3d4 */ + flowId: string | null; + flowName: string | null; + /** Format: date-time */ createdAt: string; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - updatedAt: string; /** Format: date-time */ - deletedAt?: string | null; + updatedAt: string; + usageSummary?: { + eventsIn24h: number; + healthy: boolean; + }; }; - FlowSettingsSummary: { - /** @example cfg_a1b2c3d4 */ + DeploymentDetailResponse: { + /** @example dep_a1b2c3d4 */ id: string; - name: string; /** * @example web * @enum {string} */ - platform: 'web' | 'server'; + type: 'web' | 'server'; + /** @example k7m2x9p4q1w8 */ + slug: string; + target: string | null; + label: string | null; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example cloud + * @enum {string} */ - createdAt: string; + origin: 'cloud' | 'self-hosted'; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example active + * @enum {string} */ + status: + | 'idle' + | 'deploying' + | 'published' + | 'active' + | 'stopped' + | 'failed'; + currentVersion: components['schemas']['DeploymentVersionDetail'] | null; + versions: components['schemas']['DeploymentVersionHistoryEntry'][]; + error: components['schemas']['DeploymentError'] | null; + recentErrors?: + | { + message: string; + count: number; + firstSeen: string; + lastSeen: string; + }[] + | null; + recentLogs?: + | { + time: string; + level: string; + message: string; + }[] + | null; + url: string | null; + selfHosted: { + /** Format: date-time */ + lastHeartbeatAt: string; + instanceId: string | null; + cliVersion: string | null; + healthy: boolean; + } | null; + /** Format: date-time */ + lastHeartbeatAt?: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ updatedAt: string; }; - FlowSummary: { - /** @example flow_a1b2c3d4 */ - id: string; - /** @example my-website-flow */ - name: string; - summary?: string; - settings?: components['schemas']['FlowSettingsListItem'][]; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - createdAt: string; + DeploymentVersionDetail: { + number: number; + status: string; + source: { + type: string; + flowId?: string; + flowSettingsId?: string; + configHash?: string; + }; + errorMessage: string | null; + errorCode: string | null; + /** Format: date-time */ + publishedAt: string; + publishedBy: string | null; + }; + DeploymentVersionHistoryEntry: { + versionNumber: number; + status: string; + source: string; + errorCode: string | null; + errorMessage: string | null; + errorPhase: string | null; + errorDetail?: string | null; + /** Format: date-time */ + publishedAt: string; + }; + DeploymentError: { + code: string; + message: string; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example bundle + * @enum {string} */ - updatedAt: string; - /** Format: date-time */ - deletedAt: string | null; + phase: 'preflight' | 'deploy' | 'bundle' | 'publish' | 'provision'; + detail?: string; }; - FlowSettingsListItem: { - /** @example cfg_a1b2c3d4 */ + CreateDeploymentResponse: { + /** @example dep_a1b2c3d4 */ id: string; - name: string; /** * @example web * @enum {string} */ - platform: 'web' | 'server'; - serving: components['schemas']['ServingStatus']; - latestAttempt: components['schemas']['LatestAttemptStatus']; - deploymentUrl: string | null; - deployedAt: string | null; - }; - /** @enum {string} */ - ServingStatus: 'live' | 'none'; - /** @enum {string|null} */ - LatestAttemptStatus: - | 'idle' - | 'deploying' - | 'published' - | 'active' - | 'stopped' - | 'failed' - | null; - Version: { - /** @example 1 */ - version: number; + type: 'web' | 'server'; + /** @example k7m2x9p4q1w8 */ + slug: string; + target: string | null; + label: string | null; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example cloud + * @enum {string} */ - createdAt: string; + origin: 'cloud' | 'self-hosted'; /** - * @example user + * @example active * @enum {string} */ - createdBy: 'user' | 'auto_save' | 'restore' | 'deploy' | 'preview'; - /** @example sha256:abc123... */ - contentHash?: string; + status: + | 'idle' + | 'deploying' + | 'published' + | 'active' + | 'stopped' + | 'failed'; + serving: components['schemas']['ServingStatus']; + currentVersionNumber: number | null; + url: string | null; + /** @example flow_a1b2c3d4 */ + flowId: string | null; + flowName: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + usageSummary?: { + eventsIn24h: number; + healthy: boolean; + }; }; - Project: { - /** @example proj_x7y8z9 */ - id: string; - /** @example My Website */ - name: string; + StartDeploymentResponse: + | { + /** @example dep_a1b2c3d4 */ + deploymentId: string; + /** @example k7m2x9p4q1w8 */ + slug: string; + target: string | null; + /** + * @example web + * @enum {string} + */ + type: 'web' | 'server'; + /** @enum {string} */ + status: 'deploying'; + settingsId?: string; + versionId: string; + versionNumber: number; + } + | { + deploymentId: string; + /** @enum {string} */ + status: 'already_created'; + }; + DeploymentStreamStatusEvent: { + status: string; + substatus: string | null; /** - * @example owner + * @example web * @enum {string} */ - role: 'owner' | 'admin' | 'member' | 'deployer' | 'viewer'; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ + type: 'web' | 'server'; + target: string | null; + containerUrl: string | null; + errorCode: string | null; + errorMessage: string | null; + /** Format: date-time */ createdAt: string; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ + /** Format: date-time */ updatedAt: string; - /** @example 3 */ - memberCount: number; - /** @example 5 */ - flowCount: number; - /** @example 2 */ - deploymentCount: number; - /** @example false */ - isDemo: boolean; }; - Member: { - userId: string; - /** Format: email */ - email: string; - /** @enum {string} */ - role: 'owner' | 'admin' | 'member' | 'deployer' | 'viewer'; + ListDeploymentsResponse: { + deployments: components['schemas']['DeploymentSummary'][]; + total: number; + limit: number; + offset: number; + nextCursor: string | null; + }; + UpdateDeploymentResponse: { + /** @example dep_a1b2c3d4 */ + id: string; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example web + * @enum {string} + */ + type: 'web' | 'server'; + /** @example k7m2x9p4q1w8 */ + slug: string; + target: string | null; + label: string | null; + /** + * @example cloud + * @enum {string} + */ + origin: 'cloud' | 'self-hosted'; + /** + * @example active + * @enum {string} */ + status: + | 'idle' + | 'deploying' + | 'published' + | 'active' + | 'stopped' + | 'failed'; + /** Format: date-time */ createdAt: string; + /** Format: date-time */ + updatedAt: string; }; - DeleteAccountRequest: { - /** @example me@example.com */ - confirm: string; - }; - DeleteAccountBlocked: { - error: { - /** - * @example SOLE_OWNER - * @enum {string} - */ - code: 'SOLE_OWNER'; - message: string; - details: { - /** - * @example [ - * "proj_abc123" - * ] - */ - projects: string[]; - }; - }; - }; - AccountExportResponse: { - /** @example 2026-06-10T12:00:00.000Z */ - exportedAt: string; - profile: { - /** @example user_a1b2c3d4 */ + LatestDeploymentsByFlow: { + [key: string]: { id: string; - /** @example me@example.com */ - email: string; - displayName: string | null; + flowId: string; + slug: string; + type: string; + status: string; + target: string | null; + containerUrl: string | null; createdAt: string; - lastLoginAt: string | null; - /** @example user */ - globalRole: string; - traits: string[]; }; - memberships: { - projectId: string; - projectName: string; - role: string; - joinedAt: string; - }[]; - apiTokens: { - id: string; - name: string; - projectId: string | null; - origin: string; - createdAt: string; - lastUsedAt: string | null; - expiresAt: string | null; - revokedAt: string | null; - }[]; - sessions: { - id: string; - createdAt: string; - expiresAt: string; - lastTouchedAt: string; - }[]; - mcpTokens: { - id: string; - name: string; - createdAt: string; - lastUsedAt: string | null; - expiresAt: string; - revokedAt: string | null; - }[]; - mcpSessions: { - id: string; - projectId: string | null; - createdAt: string; - lastActiveAt: string; - expiresAt: string; - messages: { - seq: number; - role: string; - content?: unknown; - createdAt: string; - }[]; - }[]; - feedback: { - id: string; - projectId: string | null; - text: string; - source: string; - createdAt: string; - }[]; - invitations: { - id: string; - projectId: string; - invitedEmail: string; - role: string; + }; + PublishVersionResponse: { + versionNumber: number; + versionId: string; + /** @example dep_a1b2c3d4 */ + deploymentId: string; + /** @enum {string} */ + status: 'deploying'; + source: + | { + /** @enum {string} */ + type: 'flow'; + flowId: string; + flowSettingsName: string; + } + | { + /** @enum {string} */ + type: 'config'; + }; + /** Format: date-time */ + createdAt: string; + }; + ListDeploymentVersionsResponse: { + versions: { + number: number; status: string; - createdAt: string; - expiresAt: string; - acceptedAt: string | null; - declinedAt: string | null; - cancelledAt: string | null; + source: { + type: string; + flowId?: string; + flowSettingsId?: string; + configHash?: string; + }; + errorMessage: string | null; + errorCode: string | null; + bundlePath: string | null; + /** Format: date-time */ + publishedAt: string; + publishedBy: string | null; }[]; + total: number; + limit: number; + offset: number; }; - ApiTokenSummary: { - /** @example tok_a1b2c3d4 */ + ListFlowReleasesResponse: { + releases: components['schemas']['FlowRelease'][]; + total: number; + limit: number; + offset: number; + }; + FlowRelease: { id: string; - /** @example CI Pipeline */ - name: string; - /** @example sk-walkeros-abcd */ - prefix: string; - /** @example manual */ - origin: string; - /** @example null */ - projectId: string | null; - /** @example null */ - scopes: string[] | null; + /** @example dep_a1b2c3d4 */ + deploymentId: string; + /** @example k7m2x9p4q1w8 */ + deploymentSlug: string | null; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example web + * @enum {string|null} */ + deploymentType: 'web' | 'server' | null; + versionNumber: number; + /** @example ver_a1b2c3d4 */ + flowVersionId: string | null; + flowVersionNumber: number | null; + status: string; + source: string; + errorCode: string | null; + /** Format: date-time */ createdAt: string; + createdBy: string | null; + createdByLabel: string | null; + rationale?: components['schemas']['ReleaseRationaleSummary'] | null; + }; + ReleaseRationaleSummary: { + hasHumanText: boolean; + hasGeneratedSummary: boolean; + firstLine: string | null; + }; + ReleaseContentResponse: { + /** @example ver_a1b2c3d4 */ + versionId: string; + /** @example 22 */ + versionNumber: number; + content: components['schemas']['FlowConfig']; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - lastUsedAt: string | null; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - expiresAt: string | null; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - revokedAt: string | null; + createdAt: string; + /** @enum {string} */ + createdBy: 'user' | 'auto_save' | 'restore' | 'deploy' | 'preview'; }; - FlowSettingsDetail: { - /** @example cfg_a1b2c3d4 */ - id: string; - name: string; + ReleaseDiff: { + /** @example ver_a1b2c3d4 */ + prevVersionId: string; + prevVersionNumber: number; + text: string; + contentIdentical: boolean; + }; + ReleaseDetailResponse: { + /** @example ver_a1b2c3d4 */ + versionId: string; + versionNumber: number; + contentHash: string | null; /** - * @example web - * @enum {string} - */ - platform: 'web' | 'server'; - config: { - [key: string]: unknown; - }; - deployment?: { - id: string; - status: string; - type: string; - containerUrl?: string | null; - publicUrl?: string | null; - errorMessage?: string | null; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - createdAt: string; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - updatedAt: string; - } | null; + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + createdBy: string; + rationale: components['schemas']['VersionAnnotation'] | null; + diff: components['schemas']['ReleaseDiff'] | null; + }; + VersionAnnotation: { + /** @example ver_a1b2c3d4 */ + versionId: string; + humanText: string | null; + generatedSummary: string | null; + /** @example user_a1b2c3d4 */ + author: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -9270,20 +11669,16 @@ export interface components { */ updatedAt: string; }; - DeploySettingsResponse: { - deploymentId: string; - /** @example cfg_a1b2c3d4 */ - settingsId: string; - status: string; + ListVersionAnnotationsResponse: { + annotations: components['schemas']['VersionAnnotation'][]; }; - FlowDetailResponse: { - /** @example flow_a1b2c3d4 */ - id: string; - /** @example my-website-flow */ - name: string; - config: components['schemas']['FlowConfig']; - settings?: components['schemas']['FlowSettingsEnriched'][]; - bundleId?: string | null; + UpsertVersionAnnotationResponse: { + /** @example ver_a1b2c3d4 */ + versionId: string; + humanText: string | null; + generatedSummary: string | null; + /** @example user_a1b2c3d4 */ + author: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -9294,48 +11689,35 @@ export interface components { * @example 2026-01-26T14:30:00.000Z */ updatedAt: string; - /** Format: date-time */ - deletedAt: string | null; }; - FlowSettingsEnriched: { + ListHubThreadsResponse: { + threads: components['schemas']['HubThread'][]; + hasMoreThreads: boolean; + }; + HubThread: { + /** @example thr_a1b2c3d4 */ id: string; - name: string; - /** @enum {string} */ - platform: 'web' | 'server'; - deployment: { - id: string; - slug: string; - status: string; - type: string; - target: string | null; - containerUrl: string | null; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - createdAt: string; - updatedAt: string | null; - } | null; - serving: components['schemas']['ServingStatus']; - latestAttempt: components['schemas']['LatestAttemptStatus']; - deployedAt: string | null; /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z + * @example release + * @enum {string} */ - createdAt: string; + anchorType: 'step' | 'entity_action' | 'release' | 'contract' | 'tag'; + anchorKey: string; + anchorLabel: string; + /** + * @example open + * @enum {string} + */ + status: 'open' | 'resolved'; + resolvedByVersionId: string | null; + resolvedByVersionNumber: number | null; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - updatedAt: string; - }; - FlowUpdateResponse: { - /** @example flow_a1b2c3d4 */ - id: string; - /** @example my-website-flow */ - name: string; - config: components['schemas']['FlowConfig']; + resolvedAt: string | null; + resolvedBy: string | null; + createdBy: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -9346,382 +11728,340 @@ export interface components { * @example 2026-01-26T14:30:00.000Z */ updatedAt: string; + messageCount: number; + messages?: components['schemas']['HubMessage'][]; + hasMoreMessages?: boolean; }; - CreateProjectResponse: { - /** @example proj_x7y8z9 */ + HubMessage: { id: string; - name: string; + /** @example user_a1b2c3d4 */ + author: string; + text: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ createdAt: string; }; - UpdateProjectResponse: { - /** @example proj_x7y8z9 */ - id: string; - name: string; - /** - * Format: date-time - * @example 2026-01-26T14:30:00.000Z - */ - updatedAt: string; - }; - DeploymentSummary: { - /** @example dep_a1b2c3d4 */ + HubThreadResponse: { + /** @example thr_a1b2c3d4 */ id: string; /** - * @example web - * @enum {string} - */ - type: 'web' | 'server'; - /** @example k7m2x9p4q1w8 */ - slug: string; - target: string | null; - label: string | null; - /** - * @example cloud + * @example release * @enum {string} */ - origin: 'cloud' | 'self-hosted'; + anchorType: 'step' | 'entity_action' | 'release' | 'contract' | 'tag'; + anchorKey: string; + anchorLabel: string; /** - * @example active + * @example open * @enum {string} */ - status: - | 'idle' - | 'deploying' - | 'published' - | 'active' - | 'stopped' - | 'failed'; - serving: components['schemas']['ServingStatus']; - currentVersionNumber: number | null; - url: string | null; - /** @example flow_a1b2c3d4 */ - flowId: string | null; - flowName: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - usageSummary?: { - eventsIn24h: number; - healthy: boolean; - }; - }; - DeploymentDetailResponse: { - /** @example dep_a1b2c3d4 */ - id: string; + status: 'open' | 'resolved'; + resolvedByVersionId: string | null; + resolvedByVersionNumber: number | null; /** - * @example web - * @enum {string} + * Format: date-time + * @example 2026-01-26T14:30:00.000Z */ - type: 'web' | 'server'; - /** @example k7m2x9p4q1w8 */ - slug: string; - target: string | null; - label: string | null; + resolvedAt: string | null; + resolvedBy: string | null; + createdBy: string; /** - * @example cloud - * @enum {string} + * Format: date-time + * @example 2026-01-26T14:30:00.000Z */ - origin: 'cloud' | 'self-hosted'; + createdAt: string; /** - * @example active - * @enum {string} + * Format: date-time + * @example 2026-01-26T14:30:00.000Z */ - status: - | 'idle' - | 'deploying' - | 'published' - | 'active' - | 'stopped' - | 'failed'; - currentVersion: components['schemas']['DeploymentVersionDetail']; - versions: components['schemas']['DeploymentVersionHistoryEntry'][]; - error: components['schemas']['DeploymentError']; - recentErrors?: - | { - message: string; - count: number; - firstSeen: string; - lastSeen: string; - }[] - | null; - recentLogs?: - | { - time: string; - level: string; - message: string; - }[] - | null; - url: string | null; - selfHosted: { - /** Format: date-time */ - lastHeartbeatAt: string; - instanceId: string | null; - cliVersion: string | null; - healthy: boolean; - } | null; - /** Format: date-time */ - lastHeartbeatAt?: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ updatedAt: string; + messageCount: number; + messages?: components['schemas']['HubMessage'][]; + hasMoreMessages?: boolean; }; - DeploymentVersionDetail: { - number: number; - status: string; - source: { - type: string; - flowId?: string; - flowSettingsId?: string; - configHash?: string; - }; - errorMessage: string | null; - errorCode: string | null; - /** Format: date-time */ - publishedAt: string; - publishedBy: string | null; - } | null; - DeploymentVersionHistoryEntry: { - versionNumber: number; - status: string; - source: string; - errorCode: string | null; - errorMessage: string | null; - errorPhase: string | null; - errorDetail?: string | null; - /** Format: date-time */ - publishedAt: string; + ListKnowledgeResponse: { + entries: components['schemas']['KnowledgeEntry'][]; + hasMoreEntries: boolean; }; - DeploymentError: { - code: string; - message: string; + KnowledgeEntry: + | components['schemas']['KnowledgeThread'] + | components['schemas']['KnowledgeDescription']; + KnowledgeThread: { + id: string; + anchorKey: string; + anchorLabel: string; + frameId: string | null; + frameName: string | null; + flowId: string | null; + subjectKey: string | null; + spatial: components['schemas']['KnowledgeSpatial'] | null; + validity: components['schemas']['KnowledgeValidity']; + /** @enum {string} */ + freshness: 'current' | 'subject_changed' | 'unknown'; + author: components['schemas']['KnowledgeAuthor']; + /** @enum {string} */ + source: 'tag_mode' | 'hub' | 'mcp'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; /** - * @example bundle + * @description discriminator enum property added by openapi-typescript * @enum {string} */ - phase: 'preflight' | 'deploy' | 'bundle' | 'publish' | 'provision'; - detail?: string; - } | null; - CreateDeploymentResponse: { - /** @example dep_a1b2c3d4 */ - id: string; + kind: 'thread'; /** - * @example web + * @example tag * @enum {string} */ - type: 'web' | 'server'; - /** @example k7m2x9p4q1w8 */ - slug: string; - target: string | null; - label: string | null; + anchorType: + | 'step' + | 'entity_action' + | 'release' + | 'contract' + | 'tag' + | 'page'; /** - * @example cloud + * @example open * @enum {string} */ - origin: 'cloud' | 'self-hosted'; + status: 'open' | 'resolved'; /** - * @example active - * @enum {string} + * Format: date-time + * @example 2026-01-26T14:30:00.000Z */ - status: - | 'idle' - | 'deploying' - | 'published' - | 'active' - | 'stopped' - | 'failed'; - serving: components['schemas']['ServingStatus']; - currentVersionNumber: number | null; - url: string | null; - /** @example flow_a1b2c3d4 */ - flowId: string | null; - flowName: string | null; - /** Format: date-time */ createdAt: string; - /** Format: date-time */ - updatedAt: string; - usageSummary?: { - eventsIn24h: number; - healthy: boolean; + messageCount: number; + messages?: components['schemas']['KnowledgeMessage'][]; + hasMoreMessages?: boolean; + }; + KnowledgeSpatial: { + at: { + x: number; + y: number; + }; + element?: { + [key: string]: unknown; }; }; - StartDeploymentResponse: + KnowledgeValidity: | { - /** @example dep_a1b2c3d4 */ - deploymentId: string; - /** @example k7m2x9p4q1w8 */ - slug: string; - target: string | null; - /** - * @example web - * @enum {string} - */ - type: 'web' | 'server'; /** @enum {string} */ - status: 'deploying'; - settingsId?: string; + tier: 'release'; versionId: string; versionNumber: number; + promoted: boolean; } | { - deploymentId: string; /** @enum {string} */ - status: 'already_created'; + tier: 'draft'; + versionId?: string; + } + | { + /** @enum {string} */ + tier: 'none'; }; - DeploymentStreamStatusEvent: { - status: string; - substatus: string | null; + KnowledgeAuthor: { + /** @enum {string} */ + kind: 'user' | 'preview' | 'agent'; + id: string | null; + label: string; + }; + KnowledgeMessage: { + id: string; + /** @example user_a1b2c3d4 */ + author: string; + /** @example ayla@elbwalker.com */ + authorLabel: string; + text: string; /** - * @example web - * @enum {string} + * Format: date-time + * @example 2026-01-26T14:30:00.000Z */ - type: 'web' | 'server'; - target: string | null; - containerUrl: string | null; - errorCode: string | null; - errorMessage: string | null; - /** Format: date-time */ createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - ListDeploymentsResponse: { - deployments: components['schemas']['DeploymentSummary'][]; - total: number; - limit: number; - offset: number; - nextCursor: string | null; + clientMessageId: string | null; }; - UpdateDeploymentResponse: { - /** @example dep_a1b2c3d4 */ + KnowledgeDescription: { id: string; + anchorKey: string; + anchorLabel: string; + frameId: string | null; + frameName: string | null; + flowId: string | null; + subjectKey: string | null; + spatial: components['schemas']['KnowledgeSpatial'] | null; + validity: components['schemas']['KnowledgeValidity']; + /** @enum {string} */ + freshness: 'current' | 'subject_changed' | 'unknown'; + author: components['schemas']['KnowledgeAuthor']; + /** @enum {string} */ + source: 'tag_mode' | 'hub' | 'mcp'; /** - * @example web + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** + * @description discriminator enum property added by openapi-typescript * @enum {string} */ - type: 'web' | 'server'; - /** @example k7m2x9p4q1w8 */ - slug: string; - target: string | null; - label: string | null; + kind: 'description'; /** - * @example cloud + * @example tag * @enum {string} */ - origin: 'cloud' | 'self-hosted'; + anchorType: 'tag' | 'page'; + body: string; + }; + KnowledgeThreadResponse: { + id: string; + anchorKey: string; + anchorLabel: string; + frameId: string | null; + frameName: string | null; + flowId: string | null; + subjectKey: string | null; + spatial: components['schemas']['KnowledgeSpatial'] | null; + validity: components['schemas']['KnowledgeValidity']; + /** @enum {string} */ + freshness: 'current' | 'subject_changed' | 'unknown'; + author: components['schemas']['KnowledgeAuthor']; + /** @enum {string} */ + source: 'tag_mode' | 'hub' | 'mcp'; /** - * @example active + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + updatedAt: string; + /** @enum {string} */ + kind: 'thread'; + /** + * @example tag * @enum {string} */ - status: - | 'idle' - | 'deploying' - | 'published' - | 'active' - | 'stopped' - | 'failed'; - /** Format: date-time */ + anchorType: + | 'step' + | 'entity_action' + | 'release' + | 'contract' + | 'tag' + | 'page'; + /** + * @example open + * @enum {string} + */ + status: 'open' | 'resolved'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ createdAt: string; - /** Format: date-time */ + messageCount: number; + messages?: components['schemas']['KnowledgeMessage'][]; + hasMoreMessages?: boolean; + }; + KnowledgeDescriptionResponse: { + id: string; + anchorKey: string; + anchorLabel: string; + frameId: string | null; + frameName: string | null; + flowId: string | null; + subjectKey: string | null; + spatial: components['schemas']['KnowledgeSpatial'] | null; + validity: components['schemas']['KnowledgeValidity']; + /** @enum {string} */ + freshness: 'current' | 'subject_changed' | 'unknown'; + author: components['schemas']['KnowledgeAuthor']; + /** @enum {string} */ + source: 'tag_mode' | 'hub' | 'mcp'; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ updatedAt: string; + /** @enum {string} */ + kind: 'description'; + /** + * @example tag + * @enum {string} + */ + anchorType: 'tag' | 'page'; + body: string; }; - LatestDeploymentsByFlow: { - [key: string]: { - id: string; - flowId: string; - slug: string; - type: string; - status: string; - target: string | null; - containerUrl: string | null; - createdAt: string; + FrameInput: { + name: string; + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ + parentId: string | null; + placements: components['schemas']['FramePlacement'][]; + size: components['schemas']['PlanSize']; + marks: { + [key: string]: unknown; }; - }; - PublishVersionResponse: { - versionNumber: number; - versionId: string; - /** @example dep_a1b2c3d4 */ - deploymentId: string; + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ + extends: string | null; + source: components['schemas']['FrameSource']; /** @enum {string} */ - status: 'deploying'; - source: - | { - /** @enum {string} */ - type: 'flow'; - flowId: string; - flowSettingsName: string; - } - | { - /** @enum {string} */ - type: 'config'; - }; - /** Format: date-time */ - createdAt: string; - }; - ListDeploymentVersionsResponse: { - versions: { - number: number; - status: string; - source: { - type: string; - flowId?: string; - flowSettingsId?: string; - configHash?: string; - }; - errorMessage: string | null; - errorCode: string | null; - bundlePath: string | null; - /** Format: date-time */ - publishedAt: string; - publishedBy: string | null; - }[]; - total: number; - limit: number; - offset: number; - }; - ListFlowReleasesResponse: { - releases: components['schemas']['FlowRelease'][]; - total: number; - limit: number; - offset: number; + origin: 'drawn' | 'imported' | 'observed'; + flowId: string | null; }; - FlowRelease: { + FramePlacement: { id: string; - /** @example dep_a1b2c3d4 */ - deploymentId: string; - /** @example k7m2x9p4q1w8 */ - deploymentSlug: string | null; - /** - * @example web - * @enum {string|null} - */ - deploymentType: 'web' | 'server' | null; - versionNumber: number; - /** @example ver_a1b2c3d4 */ - flowVersionId: string | null; - flowVersionNumber: number | null; - status: string; - source: string; - errorCode: string | null; - /** Format: date-time */ - createdAt: string; - createdBy: string | null; + rect: components['schemas']['PlanRect']; + selector?: string; + anchor?: { + [key: string]: unknown; + }; }; - ListVersionAnnotationsResponse: { - annotations: components['schemas']['VersionAnnotation'][]; + PlanRect: { + x: number; + y: number; + w: number; + h: number; }; - VersionAnnotation: { - /** @example ver_a1b2c3d4 */ - versionId: string; - humanText: string | null; - generatedSummary: string | null; - /** @example user_a1b2c3d4 */ - author: string; + PlanSize: { + width: number; + height: number; + }; + FrameSource: + | { + /** @enum {string} */ + kind: 'page'; + key: string; + url: string; + } + | { + /** @enum {string} */ + kind: 'figma'; + fileKey: string; + nodeId: string; + } + | { + /** @enum {string} */ + kind: 'image'; + } + | null; + Frame: { + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ + id: string; + projectId: string; + name: string; + parentId: string | null; + placements: components['schemas']['FramePlacement'][]; + size: components['schemas']['PlanSize']; + marks: { + [key: string]: unknown; + }; + extends: string | null; + source: components['schemas']['FrameSource']; + /** @enum {string} */ + origin: 'drawn' | 'imported' | 'observed'; + flowId: string | null; + screenshot: components['schemas']['FrameScreenshot'] | null; + version: number; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -9732,102 +12072,131 @@ export interface components { * @example 2026-01-26T14:30:00.000Z */ updatedAt: string; - }; - UpsertVersionAnnotationResponse: { - /** @example ver_a1b2c3d4 */ - versionId: string; - humanText: string | null; - generatedSummary: string | null; - /** @example user_a1b2c3d4 */ - author: string; + createdBy: string; + updatedBy: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - createdAt: string; + deletedAt: string | null; + }; + FrameScreenshot: { + assetId: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - updatedAt: string; - }; - ListHubThreadsResponse: { - threads: components['schemas']['HubThread'][]; - hasMoreThreads: boolean; + capturedAt: string; + size: components['schemas']['PlanSize']; + dpr: number; + capturedRect: components['schemas']['PlanRect']; }; - HubThread: { - /** @example thr_a1b2c3d4 */ + FrameLean: { + /** @example frm_V1StGXR8Z5jdHi6BmyT7K */ id: string; - /** - * @example release - * @enum {string} - */ - anchorType: 'step' | 'entity_action' | 'release' | 'contract' | 'tag'; - anchorKey: string; - anchorLabel: string; - /** - * @example open - * @enum {string} - */ - status: 'open' | 'resolved'; - resolvedByVersionId: string | null; - resolvedByVersionNumber: number | null; + projectId: string; + name: string; + parentId: string | null; + placements: components['schemas']['FramePlacement'][]; + size: components['schemas']['PlanSize']; + extends: string | null; + source: components['schemas']['FrameSource']; + /** @enum {string} */ + origin: 'drawn' | 'imported' | 'observed'; + flowId: string | null; + screenshot: components['schemas']['FrameScreenshot'] | null; + version: number; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - resolvedAt: string | null; - resolvedBy: string | null; - createdBy: string; + createdAt: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - updatedAt: string; - messageCount: number; - messages?: components['schemas']['HubMessage'][]; - hasMoreMessages?: boolean; + deletedAt: string | null; }; - HubMessage: { + FrameListResponse: { + frames: components['schemas']['Frame'][]; + }; + FrameLeanListResponse: { + frames: components['schemas']['FrameLean'][]; + }; + PutFrameResponse: { + version: number; + }; + FrameConflictResponse: { + error: { + /** @enum {string} */ + code: 'FRAME_VERSION_CONFLICT'; + message: string; + }; + head: components['schemas']['Frame']; + }; + CanvasDocument: { + /** @enum {number} */ + v: 1; + nodes: components['schemas']['CanvasNodeEntry'][]; + edges: components['schemas']['CanvasEdgeEntry'][]; + hidden: string[]; + }; + CanvasNodeEntry: { + kind: string; + ref: string; + position: components['schemas']['CanvasPoint']; + parent?: string; + size?: { + width: number; + height: number; + }; + label?: string; + }; + CanvasPoint: { + x: number; + y: number; + }; + CanvasEdgeEntry: { id: string; - /** @example user_a1b2c3d4 */ - author: string; - text: string; + /** @enum {string} */ + kind: 'navigation'; + from: string; + to: string; + label?: string; + }; + Canvas: { + /** @example cnv_V1StGXR8Z5jdHi6BmyT7K */ + id: string; + projectId: string; + name: string; + document: components['schemas']['CanvasDocument']; + version: number; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ createdAt: string; - }; - HubThreadResponse: { - /** @example thr_a1b2c3d4 */ - id: string; - /** - * @example release - * @enum {string} - */ - anchorType: 'step' | 'entity_action' | 'release' | 'contract' | 'tag'; - anchorKey: string; - anchorLabel: string; - /** - * @example open - * @enum {string} - */ - status: 'open' | 'resolved'; - resolvedByVersionId: string | null; - resolvedByVersionNumber: number | null; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - resolvedAt: string | null; - resolvedBy: string | null; + updatedAt: string; createdBy: string; + updatedBy: string; + }; + CanvasLean: { + /** @example cnv_V1StGXR8Z5jdHi6BmyT7K */ + id: string; + projectId: string; + name: string; + version: number; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -9838,9 +12207,22 @@ export interface components { * @example 2026-01-26T14:30:00.000Z */ updatedAt: string; - messageCount: number; - messages?: components['schemas']['HubMessage'][]; - hasMoreMessages?: boolean; + createdBy: string; + updatedBy: string; + }; + CanvasListResponse: { + canvases: components['schemas']['CanvasLean'][]; + }; + PutCanvasResponse: { + version: number; + }; + CanvasConflictResponse: { + error: { + /** @enum {string} */ + code: 'CANVAS_VERSION_CONFLICT'; + message: string; + }; + head: components['schemas']['Canvas']; }; SummarizeReleaseResponse: { /** @enum {string} */ @@ -9965,8 +12347,8 @@ export interface components { observedFlowName: string | null; serverFlowName: string | null; serverEndpoint: string | null; - web: components['schemas']['ObserveSessionWeb']; - server: components['schemas']['ObserveSessionServer']; + web: components['schemas']['ObserveSessionWeb'] | null; + server: components['schemas']['ObserveSessionServer'] | null; /** Format: date-time */ expiresAt: string; recordsReceived: number; @@ -9984,12 +12366,12 @@ export interface components { /** Format: uri */ url?: string; binding?: string; - } | null; + }; ObserveSessionServer: { /** Format: uri */ endpoint: string | null; env: components['schemas']['ObserveSessionServerEnv']; - } | null; + }; ObserveSessionServerEnv: { /** Format: uri */ WALKEROS_OBSERVER_URL: string; @@ -10671,33 +13053,6 @@ export interface components { /** @enum {boolean} */ ok: true; }; - CreateMcpTokenRequest: { - name: string; - ttlSeconds?: number; - }; - CreateMcpTokenResponse: { - id: string; - name: string; - token: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - expiresAt: string; - }; - ListMcpTokensResponse: { - tokens: components['schemas']['McpTokenSummary'][]; - }; - McpTokenSummary: { - id: string; - name: string; - audience: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - lastUsedAt: string | null; - /** Format: date-time */ - expiresAt: string; - }; PackageCatalogResponse: { catalog: components['schemas']['PackageCatalogEntry'][]; count: number; @@ -10802,27 +13157,6 @@ export interface components { isCurrent: boolean; }[]; }; - DeviceCodeResponse: { - deviceCode: string; - userCode: string; - expiresIn: number; - interval: number; - }; - ApproveDeviceResponse: { - success: boolean; - }; - ApproveDeviceRequest: { - userCode: string; - }; - DeviceTokenResponse: { - token: string; - email: string; - userId: string; - }; - DeviceTokenRequest: { - deviceCode: string; - hostname?: string; - }; ListProjectsResponse: { projects: components['schemas']['Project'][]; total: number; @@ -10975,18 +13309,32 @@ export interface components { /** @enum {string} */ createdBy: 'user' | 'auto_save' | 'restore' | 'deploy' | 'preview'; }; - ListApiTokensResponse: { - tokens: components['schemas']['ApiTokenSummary'][]; + ListAutomationTokensResponse: { + tokens: components['schemas']['AutomationTokenSummary'][]; }; - CreateApiTokenResponse: { + CreateAutomationTokenResponse: { /** @example tok_a1b2c3d4 */ id: string; /** @example CI Pipeline */ name: string; - /** @example sk-walkeros-abcd1234... */ + /** @example wos_pat_a1b2c3d4... */ token: string; - /** @example sk-walkeros-abcd */ - prefix: string; + /** @example wos_pat_a1b2 */ + tokenPrefix: string; + /** + * @example [ + * "read", + * "write" + * ] + */ + scope: string[]; + /** + * @example [ + * "api", + * "mcp" + * ] + */ + audience: string[]; /** * Format: date-time * @example 2026-01-26T14:30:00.000Z @@ -10996,15 +13344,18 @@ export interface components { * Format: date-time * @example 2026-01-26T14:30:00.000Z */ - expiresAt: string | null; - /** @example null */ - projectId: string | null; + expiresAt: string; }; - CreateApiTokenRequest: { + CreateAutomationTokenRequest: { /** @example CI Pipeline */ name: string; + /** + * @example read write + * @enum {string} + */ + scope: 'read' | 'read write'; /** @example 90 */ - expiresInDays?: number | null; + expiresInDays: 30 | 90 | 180 | 365; }; BundleResponse: { bundleId: string; @@ -11079,6 +13430,21 @@ export interface components { DeclineInvitationResponse: { message: string; }; + ScreenshotUploadResponse: { + /** @example fas_V1StGXR8Z5jdHi6BmyT7K */ + assetId: string; + reused: boolean; + }; + FrameScreenshotMeta: { + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + capturedAt: string; + size: components['schemas']['PlanSize']; + dpr: number; + capturedRect: components['schemas']['PlanRect']; + }; HeartbeatRequest: { /** @example a1b2c3d4e5f6 */ instanceId: string; @@ -11125,6 +13491,211 @@ export interface components { message: string; }[]; }; + OAuthClientRegistrationResponse: { + /** @example client_abc */ + client_id: string; + /** @example 1725400000 */ + client_id_issued_at: number; + client_name: string; + redirect_uris: string[]; + /** @enum {string} */ + token_endpoint_auth_method: 'none'; + grant_types: string[]; + response_types: string[]; + }; + OAuthRegistrationError: { + /** @enum {string} */ + error: 'invalid_client_metadata' | 'invalid_redirect_uri'; + error_description: string; + }; + OAuthClientRegistrationRequest: { + /** + * @example [ + * "https://claude.ai/api/mcp/auth_callback" + * ] + */ + redirect_uris: string[]; + client_name?: string; + /** @enum {string} */ + token_endpoint_auth_method?: 'none'; + grant_types?: ('authorization_code' | 'refresh_token')[]; + response_types?: 'code'[]; + /** Format: uri */ + client_uri?: string; + /** Format: uri */ + logo_uri?: string; + scope?: string; + software_id?: string; + software_version?: string; + }; + DeviceAuthorizationResponse: { + device_code: string; + /** @example WDJB-MJHT */ + user_code: string; + verification_uri: string; + verification_uri_complete: string; + /** @example 900 */ + expires_in: number; + /** @example 5 */ + interval: number; + }; + OAuthError: { + /** @example invalid_client */ + error: string; + error_description: string; + }; + DeviceAuthorizationRequest: { + /** @example walkeros-cli */ + client_id: string; + /** @example read write offline_access */ + scope?: string; + /** @example https://app.walkeros.io/api */ + resource?: string; + }; + TokenResponse: { + access_token: string; + /** @enum {string} */ + token_type: 'Bearer'; + /** @example 3600 */ + expires_in: number; + refresh_token?: string; + /** @example read write offline_access */ + scope: string; + }; + TokenRequest: { + /** + * @example authorization_code + * @enum {string} + */ + grant_type: + | 'authorization_code' + | 'refresh_token' + | 'urn:ietf:params:oauth:grant-type:device_code'; + /** @example walkeros-cli */ + client_id?: string; + client_secret?: string; + code?: string; + redirect_uri?: string; + code_verifier?: string; + refresh_token?: string; + device_code?: string; + /** @example read offline_access */ + scope?: string; + /** @example https://app.walkeros.io/api */ + resource?: string; + }; + RevocationRequest: { + token: string; + /** @enum {string} */ + token_type_hint?: 'access_token' | 'refresh_token'; + client_id?: string; + client_secret?: string; + }; + DeviceApprovalResponse: { + /** @enum {boolean} */ + success: true; + /** @enum {string} */ + decision: 'approve' | 'deny'; + }; + DeviceApprovalRequest: { + /** @example WDJB-MJHT */ + userCode: string; + /** @enum {string} */ + decision: 'approve' | 'deny'; + }; + OAuthConsentDecisionResponse: { + /** @example https://claude.ai/api/mcp/auth_callback?code=abc&state=xyz */ + redirectTo: string; + }; + OAuthConsentDecisionRequest: { + ticket: string; + /** @enum {string} */ + decision: 'allow' | 'deny'; + }; + ListOAuthGrantsResponse: { + grants: components['schemas']['OAuthGrantSummary'][]; + }; + OAuthGrantSummary: { + id: string; + clientId: string; + clientName: string; + scope: string[]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + createdAt: string; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + lastUsedAt: string | null; + }; + ListOAuthClientsResponse: { + clients: components['schemas']['OAuthClientSummary'][]; + }; + OAuthClientSummary: { + clientId: string; + /** @enum {string} */ + kind: 'dcr' | 'cimd' | 'confidential' | 'builtin'; + name: string; + redirectUris: string[]; + grantTypes: string[]; + /** @enum {string} */ + tokenEndpointAuthMethod: + | 'none' + | 'client_secret_basic' + | 'client_secret_post'; + allowedResources: ('mcp' | 'api')[]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + revokedAt: string | null; + }; + CreateOAuthClientResponse: { + clientId: string; + /** @enum {string} */ + kind: 'dcr' | 'cimd' | 'confidential' | 'builtin'; + name: string; + redirectUris: string[]; + grantTypes: string[]; + /** @enum {string} */ + tokenEndpointAuthMethod: + | 'none' + | 'client_secret_basic' + | 'client_secret_post'; + allowedResources: ('mcp' | 'api')[]; + /** + * Format: date-time + * @example 2026-01-26T14:30:00.000Z + */ + revokedAt: string | null; + clientSecret: string; + }; + CreateOAuthClientRequest: { + name: string; + redirectUris: string[]; + /** + * @default [ + * "authorization_code", + * "refresh_token" + * ] + */ + grantTypes: ('authorization_code' | 'refresh_token')[]; + /** + * @default [ + * "mcp", + * "api" + * ] + */ + allowedResources: ('mcp' | 'api')[]; + /** + * @default client_secret_basic + * @enum {string} + */ + authMethod: 'client_secret_basic' | 'client_secret_post'; + }; }; responses: never; parameters: never; diff --git a/packages/collector/CHANGELOG.md b/packages/collector/CHANGELOG.md index 280bf5d65..9d211f92e 100644 --- a/packages/collector/CHANGELOG.md +++ b/packages/collector/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/collector +## 4.6.0 + +### Patch Changes + +- 8802281: The BigQuery destination no longer applies `config.timeout` as a + deadline on the Storage Write API append stream, which killed healthy + connections roughly every ten seconds and caused reconnect churn, latency + spikes, and intermittent 5xx responses. Error logs now show the error's + message, name and status code in CLI output, and no longer include event + payloads. + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/collector/package.json b/packages/collector/package.json index 423de7945..c79d9188a 100644 --- a/packages/collector/package.json +++ b/packages/collector/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/collector", "description": "Unified platform-agnostic collector for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -32,7 +32,7 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -61,6 +61,6 @@ } ], "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } } diff --git a/packages/collector/src/__tests__/boundary-error.test.ts b/packages/collector/src/__tests__/boundary-error.test.ts index dd3ffda82..d9cc8483c 100644 --- a/packages/collector/src/__tests__/boundary-error.test.ts +++ b/packages/collector/src/__tests__/boundary-error.test.ts @@ -30,8 +30,6 @@ import { createCommand } from '../command'; type MockedPushToDestinations = jest.MockedFunction; -const SPAN_HEX = /^[0-9a-f]{16}$/; - function createTestCollector(): Collector.Instance { const mockLogger = createMockLogger(); @@ -101,19 +99,76 @@ describe('push boundary', () => { const errorMock = (collector.logger as ReturnType) .error as jest.Mock; expect(errorMock).toHaveBeenCalledTimes(1); - // The wrap mints the span id and hands the pipeline an id-stamped copy, - // so the failure log carries the event as the pipeline saw it: the - // caller's fields plus the minted id. + // The failure log identifies the event by NAME only. The full event and + // the ingest payload are deliberately absent; see the context-shape tests + // below for why. expect(errorMock).toHaveBeenCalledWith( 'push failed', expect.objectContaining({ - event: { ...event, id: expect.stringMatching(SPAN_HEX) }, - ingest, - error: expect.any(Error), + event: 'page view', + error: 'boom', }), ); }); + test('push failure context is primitives only: no event object, no ingest payload', async () => { + const collector = collectorWithIdentity(); + const push = createPush(collector, identityPrepare); + + mockedPushToDestinations.mockImplementation(() => { + throw new Error('boom'); + }); + + // A realistic payload-bearing event: the log line must identify it + // without carrying the buyer's address into stderr and the jsonl sink. + const event: WalkerOS.DeepPartialEvent = { + name: 'order complete', + data: { total: 42 }, + user: { id: 'buyer@example.com' }, + }; + + await push(event, { + id: 'test-source', + ingest: createIngest('test-source'), + }); + + const errorMock = (collector.logger as ReturnType) + .error as jest.Mock; + const context: unknown = errorMock.mock.calls[0][1]; + + expect(context).toEqual({ error: 'boom', event: 'order complete' }); + // What actually reaches Loki and the on-disk jsonl is the serialized + // form, so assert on that too: no PII, whatever the key names. + expect(JSON.stringify(context)).not.toContain('buyer@example.com'); + }); + + test('the same failure on different events yields byte-identical context (ring dedup)', async () => { + const collector = collectorWithIdentity(); + const push = createPush(collector, identityPrepare); + + const err = new Error('boom'); + mockedPushToDestinations.mockImplementation(() => { + throw err; + }); + + const options = { id: 'test-source' }; + await push({ name: 'page view', data: { path: '/a' } }, options); + await push({ name: 'page view', data: { path: '/b' } }, options); + await push({ name: 'order complete', data: { path: '/c' } }, options); + + const errorMock = (collector.logger as ReturnType) + .error as jest.Mock; + const [first, second, third] = errorMock.mock.calls.map((call: unknown[]) => + JSON.stringify(call[1]), + ); + + // Same event name, different payloads: identical context, so the error + // ring dedups instead of evicting distinct errors per failing event. + expect(second).toBe(first); + // A different event name is the ONLY thing that varies. + expect(third).toBe(first!.replace('page view', 'order complete')); + }); + test('does not log or count when push succeeds', async () => { const collector = collectorWithIdentity(); const push = createPush(collector, identityPrepare); @@ -178,12 +233,32 @@ describe('command boundary', () => { 'command failed', expect.objectContaining({ command: 'walker sentinel', - data, - error: expect.any(Error), + error: 'handler exploded', }), ); }); + test('command failure context carries the command id only, never the data payload', async () => { + const collector = createTestCollector(); + const throwingHandler = jest.fn(async () => { + throw new Error('handler exploded'); + }); + + const command = createCommand(collector, throwingHandler); + + await command('walker consent', { email: 'buyer@example.com' }); + + const errorMock = (collector.logger as ReturnType) + .error as jest.Mock; + const context: unknown = errorMock.mock.calls[0][1]; + + expect(context).toEqual({ + error: 'handler exploded', + command: 'walker consent', + }); + expect(JSON.stringify(context)).not.toContain('buyer@example.com'); + }); + test('does not log or count when command succeeds', async () => { const collector = createTestCollector(); const successHandler = jest.fn(async () => ({ diff --git a/packages/collector/src/__tests__/destination.test.ts b/packages/collector/src/__tests__/destination.test.ts index c5266d50e..f64b2fcc1 100644 --- a/packages/collector/src/__tests__/destination.test.ts +++ b/packages/collector/src/__tests__/destination.test.ts @@ -308,6 +308,35 @@ describe('Destination', () => { expect(destination.dlq).toContainEqual([event, new Error('kaputt')]); }); + test('push failure log context carries the error message, not a raw Error', async () => { + const event = createEvent(); + mockPush.mockImplementation(() => { + throw new Error('kaputt'); + }); + + // Route every scope() to one known mock so the scoped 'Push failed' + // call is observable. + const scoped = createMockLogger(); + const rootLogger = createMockLogger(); + rootLogger.scope = jest.fn().mockReturnValue(scoped); + + await pushToDestinations( + createWalkerjs({ logger: rootLogger }), + event, + {}, + { destination: createDestination() }, + ); + + const call = (scoped.error as jest.Mock).mock.calls.find( + (args) => args[0] === 'Push failed', + ); + expect(call).toBeDefined(); + expect(call?.[1]).toEqual({ error: 'kaputt', event: event.name }); + // A raw Error has no enumerable own properties: serialized it collapses + // to `"error":{}` and the cause is gone. + expect(JSON.stringify(call?.[1])).toContain('kaputt'); + }); + test('skip on denied consent', async () => { // Destination requires marketing consent const destinationWithConsent = createDestination({ diff --git a/packages/collector/src/__tests__/on-callback-error.test.ts b/packages/collector/src/__tests__/on-callback-error.test.ts index 26661fc6f..73cd91b02 100644 --- a/packages/collector/src/__tests__/on-callback-error.test.ts +++ b/packages/collector/src/__tests__/on-callback-error.test.ts @@ -65,7 +65,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'destination', - error: expect.any(Error), + error: 'dest on boom', }), ); }); @@ -87,7 +87,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'generic', - error: expect.any(Error), + error: 'generic boom', }), ); }); @@ -114,7 +114,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'source', - error: expect.any(Error), + error: 'source on boom', }), ); }); @@ -136,7 +136,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'consent', - error: expect.any(Error), + error: 'consent boom', }), ); }); @@ -157,7 +157,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'ready', - error: expect.any(Error), + error: 'ready boom', }), ); }); @@ -178,7 +178,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'run', - error: expect.any(Error), + error: 'run boom', }), ); }); @@ -201,7 +201,7 @@ describe('on.ts user-callback throws (Category B)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ kind: 'session', - error: expect.any(Error), + error: 'session boom', }), ); }); diff --git a/packages/collector/src/__tests__/report-error.test.ts b/packages/collector/src/__tests__/report-error.test.ts index a2ad35e43..340d0756d 100644 --- a/packages/collector/src/__tests__/report-error.test.ts +++ b/packages/collector/src/__tests__/report-error.test.ts @@ -87,6 +87,51 @@ describe('reportError', () => { expect(() => reportError(new Error('stream broken'))).not.toThrow(); }); + + test('orphan log meta carries the error name and transport code when present', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + // Shape of a gax GoogleError: an Error subclass with a numeric gRPC + // status code. The log line must surface both so a stream failure is + // diagnosable from logs alone. + const googleError: Error & { code?: number } = Object.assign( + new Error('Total timeout of API exceeded 10000 milliseconds'), + { code: 4 }, + ); + googleError.name = 'GoogleError'; + + reportError(googleError); + + expect(logger.error).toHaveBeenCalledWith('connection error', { + error: 'Total timeout of API exceeded 10000 milliseconds', + name: 'GoogleError', + code: 4, + }); + }); + + test('orphan log meta stays lean for a plain Error (no name/code noise)', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + ); + + reportError(new Error('stream broken')); + + expect(logger.error).toHaveBeenCalledWith('connection error', { + error: 'stream broken', + }); + }); }); describe('event-bearing form', () => { @@ -133,6 +178,32 @@ describe('reportError', () => { expect(() => reportError(new Error('x'), event)).not.toThrow(); expect(c.status.failed).toBe(1); }); + + test('event-bearing log meta carries the transport code when present', async () => { + const c = await collector({}); + const logger = createMockLogger(); + const dlq: Destination.DLQ = []; + const reportError = buildReportError( + c, + 'destination', + 'bigquery', + logger, + makeDestination(dlq), + ); + + const err: Error & { code?: number } = Object.assign( + new Error('append failed'), + { code: 14 }, + ); + + reportError(err, event); + + expect(logger.error).toHaveBeenCalledWith('report error', { + error: 'append failed', + code: 14, + event: event.name, + }); + }); }); describe('stable closure', () => { diff --git a/packages/collector/src/__tests__/source-error-visibility.test.ts b/packages/collector/src/__tests__/source-error-visibility.test.ts index 9559d1035..2df5ac497 100644 --- a/packages/collector/src/__tests__/source-error-visibility.test.ts +++ b/packages/collector/src/__tests__/source-error-visibility.test.ts @@ -40,10 +40,33 @@ describe('source factory throws (Category A)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ sourceId: 'bad', - error: expect.any(Error), + error: 'factory boom', }), ); }); + + test('the failure context serializes the message, not a blind empty error', async () => { + const { collector } = await startFlow({ + logger: { handler: () => undefined }, + }); + installMockLogger(collector); + + await initSources(collector, { + bad: { + code: () => { + throw new Error('factory boom'); + }, + } as Source.InitSource, + }); + + const context = findLoggerError(collector, 'source factory failed')?.[1]; + + expect(context).toEqual({ sourceId: 'bad', error: 'factory boom' }); + // A raw Error has no enumerable own properties, so logging the object + // itself serializes to `"error":{}` — the message is lost in exactly the + // output an operator reads to find out what broke. + expect(JSON.stringify(context)).toContain('factory boom'); + }); }); describe('source init throws (Category A)', () => { @@ -77,7 +100,7 @@ describe('source init throws (Category A)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ sourceId: 'stuck', - error: expect.any(Error), + error: 'init boom', }), ); }); @@ -110,7 +133,7 @@ describe('source queued on flush throws (Category A)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ type: 'consent', - error: expect.any(Error), + error: 'on boom', }), ); }); diff --git a/packages/collector/src/__tests__/store-cache-wrapper.test.ts b/packages/collector/src/__tests__/store-cache-wrapper.test.ts index c0fe1a804..6a57af71b 100644 --- a/packages/collector/src/__tests__/store-cache-wrapper.test.ts +++ b/packages/collector/src/__tests__/store-cache-wrapper.test.ts @@ -345,6 +345,36 @@ describe('store-cache wrapper: write path', () => { expect(logger.warn).toHaveBeenCalledTimes(1); }); + it('set: the cache-failure warning carries the error message, not a raw Error', async () => { + const backing = createBackingStore(); + const cacheStore = createMockCacheStore(); + const logger = createMockLogger(); + cacheStore.set.mockImplementationOnce(() => { + throw new Error('cache offline'); + }); + + const cacheConfig: Cache.Cache = { + rules: [{ ttl: 60 }], + }; + const wrapped = wrapStoreWithCache(backing, { + storeId: 'foo', + cacheConfig, + cacheStore, + namespace: 'foo', + logger, + }); + + await wrapped.set('user', 'alice'); + + const context = logger.warn.mock.calls[0][1]; + + expect(context).toEqual({ error: 'cache offline' }); + // A raw Error has no enumerable own properties, so logging the object + // itself serializes to `"error":{}` and the cause is gone from the + // output an operator reads. + expect(JSON.stringify(context)).toContain('cache offline'); + }); + it('set: no matching rule -> backing called, cache NOT called', async () => { const backing = createBackingStore(); const cacheStore = createMockCacheStore(); diff --git a/packages/collector/src/__tests__/transformer-init-error.test.ts b/packages/collector/src/__tests__/transformer-init-error.test.ts index 69a2256ad..491ec6925 100644 --- a/packages/collector/src/__tests__/transformer-init-error.test.ts +++ b/packages/collector/src/__tests__/transformer-init-error.test.ts @@ -86,10 +86,34 @@ describe('transformer init throws (Category A)', () => { expect(errorCall?.[1]).toEqual( expect.objectContaining({ transformer: 'bad', - error: expect.any(Error), + error: 'init boom', }), ); }); + + test('the failure context serializes the message, not a blind empty error', async () => { + const collector = createTestCollector(); + const throwingTransformer: Transformer.Instance = { + type: 'mock', + config: {}, + push: jest.fn(), + init: jest.fn(() => { + throw new Error('init boom'); + }), + }; + collector.transformers = { bad: throwingTransformer }; + + await runTransformerChain(collector, collector.transformers, ['bad'], { + name: 'page view', + }); + + const context = findLoggerError(collector, 'transformer init failed')?.[1]; + + expect(context).toEqual({ transformer: 'bad', error: 'init boom' }); + // A raw Error serializes to `"error":{}`, blanking the cause in the + // output an operator reads. + expect(JSON.stringify(context)).toContain('init boom'); + }); }); // --- helpers --- diff --git a/packages/collector/src/command.ts b/packages/collector/src/command.ts index a8162f9cb..1e4c332fa 100644 --- a/packages/collector/src/command.ts +++ b/packages/collector/src/command.ts @@ -2,6 +2,7 @@ import type { Collector, Elb } from '@walkeros/core'; import type { HandleCommandFn } from './types/collector'; import { FatalError, useHooks, tryCatchAsync } from '@walkeros/core'; import { createPushResult } from './destination'; +import { errorMeta } from './report-error'; /** * Creates the command function for the collector. @@ -28,10 +29,13 @@ export function createCommand( (err: unknown) => { if (err instanceof FatalError) throw err; collector.status.failed++; + // `command` is a low-cardinality identifier and stays; the + // arbitrary `data` payload does not. Log context is serialized into + // stderr, the error ring and the jsonl sink, where a command's + // operand (consent state, user fields) would be a PII egress. collector.logger.error('command failed', { + ...errorMeta(err), command, - data, - error: err, }); return createPushResult({ ok: false }); }, diff --git a/packages/collector/src/destination.ts b/packages/collector/src/destination.ts index 355b6fdf8..f95db4dfd 100644 --- a/packages/collector/src/destination.ts +++ b/packages/collector/src/destination.ts @@ -49,6 +49,7 @@ import { bumpDropped, ensureDestStatus, buildReportError, + errorMeta, } from './report-error'; import { reconcilePending } from './pending'; import { @@ -745,7 +746,7 @@ export async function pushToDestinations( // Log the error with destination scope const destType = destination.type || 'unknown'; collector.logger.scope(destType).error('Push failed', { - error: err, + ...errorMeta(err), event: processedEvent!.name, }); error = err; // oh no diff --git a/packages/collector/src/on.ts b/packages/collector/src/on.ts index a9e85e962..caf2d9d37 100644 --- a/packages/collector/src/on.ts +++ b/packages/collector/src/on.ts @@ -9,7 +9,7 @@ import { isArray, FatalError } from '@walkeros/core'; import { Const } from './constants'; import { tryCatch, tryCatchAsync } from '@walkeros/core'; import { mergeEnvironments } from './destination'; -import { buildReportError } from './report-error'; +import { buildReportError, errorMeta } from './report-error'; import { reconcilePending } from './pending'; import { flushSourceQueueOn, isSourceStarted } from './source'; @@ -75,7 +75,7 @@ function logOnCallbackError( collector.logger.scope('on').error('on callback failed', { kind, ...extra, - error, + ...errorMeta(error), }); } diff --git a/packages/collector/src/push.ts b/packages/collector/src/push.ts index fc5f9e75b..ba2850ae3 100644 --- a/packages/collector/src/push.ts +++ b/packages/collector/src/push.ts @@ -11,7 +11,7 @@ import { useHooks, } from '@walkeros/core'; import { pushBounded, resetOverflowFlag, warnOverflowOnce } from './buffers'; -import { bumpDropped } from './report-error'; +import { bumpDropped, errorMeta } from './report-error'; import { createEvent, enrichEvent } from './handle'; import { pushToDestinations, createPushResult } from './destination'; import { buildBaseState, journeyFields } from './observerEmit'; @@ -319,10 +319,15 @@ export function createPush( }); } collector.status.failed++; + // Identify the event by NAME only. The log context is serialized + // into stderr, the error ring and the managed-run jsonl sink, so it + // stays primitive and low-cardinality: the full event (user, + // consent, data) and the raw ingest payload would be a PII egress, + // and per-event values would make every failure look distinct to + // the ring's message dedup. collector.logger.error('push failed', { - event, - ingest: options.ingest, - error: err, + ...errorMeta(err), + event: event.name, }); return createPushResult({ ok: false }); }, diff --git a/packages/collector/src/report-error.ts b/packages/collector/src/report-error.ts index b31f39b7f..5806b6b09 100644 --- a/packages/collector/src/report-error.ts +++ b/packages/collector/src/report-error.ts @@ -114,6 +114,36 @@ function routeEventToDlq( } } +/** + * Extract loggable metadata from an unknown error: the message plus, when + * present, the error's `name` (skipped for the default 'Error', which adds no + * signal) and its transport `code` (gRPC status number or HTTP status). Keeps + * connection-error lines diagnosable from logs alone (WHICH status killed a + * stream), without serializing whole error objects into the log payload. + */ +export function errorMeta(err: unknown): { + error: string; + name?: string; + code?: string | number; +} { + const meta: { error: string; name?: string; code?: string | number } = { + error: err instanceof Error ? err.message : String(err), + }; + if (err instanceof Error && err.name && err.name !== 'Error') { + meta.name = err.name; + } + if (typeof err === 'object' && err !== null && 'code' in err) { + const withCode: { code?: unknown } = err; + if ( + typeof withCode.code === 'number' || + typeof withCode.code === 'string' + ) { + meta.code = withCode.code; + } + } + return meta; +} + /** * Builds the step-general `reportError` callback for one step's context. * @@ -157,7 +187,7 @@ export function buildReportError( collector.status.failed++; } logger.error('report error', { - error: err instanceof Error ? err.message : String(err), + ...errorMeta(err), event: event.name, }); return; @@ -167,9 +197,7 @@ export function buildReportError( // not failed. collector.status.connectionErrors[key] = (collector.status.connectionErrors[key] ?? 0) + 1; - logger.error('connection error', { - error: err instanceof Error ? err.message : String(err), - }); + logger.error('connection error', errorMeta(err)); } catch { // Contained: reportError runs on a detached tick and must never throw. } diff --git a/packages/collector/src/source.ts b/packages/collector/src/source.ts index 95ae8397c..71c28bfbe 100644 --- a/packages/collector/src/source.ts +++ b/packages/collector/src/source.ts @@ -30,7 +30,7 @@ import { runTransformerChain, cloneIngest, } from './transformer'; -import { buildReportError } from './report-error'; +import { buildReportError, errorMeta } from './report-error'; import { isStateDelivery, shouldDeliver, setMark } from './on'; import { reconcilePending } from './pending'; @@ -95,7 +95,7 @@ export async function flushSourceQueueOn( collector.logger.scope('source').error('source on flush failed', { sourceId: id, type, - error: err, + ...errorMeta(err), }); return undefined; })(type, data); @@ -458,7 +458,7 @@ export async function initSource( (err) => { collector.logger .scope('source:many') - .error(`many branch ${idx} failed`, { error: err }); + .error(`many branch ${idx} failed`, errorMeta(err)); return { ok: true } as Elb.PushResult; }, )(), @@ -598,7 +598,7 @@ export async function initSource( collector.status.failed++; collector.logger.scope('source').error('source factory failed', { sourceId, - error: err, + ...errorMeta(err), }); return undefined; }, @@ -662,7 +662,7 @@ export async function initSources( collector.status.failed++; collector.logger.scope('source').error('source init failed', { sourceId, - error: err, + ...errorMeta(err), }); })(); } diff --git a/packages/collector/src/store-cache-wrapper.ts b/packages/collector/src/store-cache-wrapper.ts index eb30cb934..ca259cc84 100644 --- a/packages/collector/src/store-cache-wrapper.ts +++ b/packages/collector/src/store-cache-wrapper.ts @@ -6,6 +6,7 @@ import { wrapCacheEnvelope, } from '@walkeros/core'; import { buildBaseState } from './observerEmit'; +import { errorMeta } from './report-error'; /** * Options passed to `wrapStoreWithCache`. Pre-resolved by `initStores` phase @@ -336,7 +337,7 @@ export function wrapStoreWithCache( ): void { const message = `store-cache(${storeId}): cache ${op} failed for "${key}"; backing succeeded, continuing`; if (logger) { - logger.warn(message, { error }); + logger.warn(message, errorMeta(error)); } else { // Defensive fallback so unit tests that do not thread a logger still // surface the failure rather than swallowing it silently. Production diff --git a/packages/collector/src/transformer.ts b/packages/collector/src/transformer.ts index fde6fe356..915d56951 100644 --- a/packages/collector/src/transformer.ts +++ b/packages/collector/src/transformer.ts @@ -58,7 +58,7 @@ import { } from '@walkeros/core'; import { buildBaseState, journeyFields } from './observerEmit'; import { getCacheStore, getStateStore } from './cache'; -import { buildReportError } from './report-error'; +import { buildReportError, errorMeta } from './report-error'; /** * Extracts transformer next configuration for chain walking. @@ -644,7 +644,7 @@ export async function runTransformerChain( .scope(`transformer:${transformer.type || 'unknown'}`) .error('transformer init failed', { transformer: transformerName, - error: err, + ...errorMeta(err), }); return false; }, @@ -825,7 +825,7 @@ export async function runTransformerChain( tryCatchAsync(runTransformerChain, (err) => { collector.logger .scope('transformer:many') - .error(`many branch ${id} failed`, { error: err }); + .error(`many branch ${id} failed`, errorMeta(err)); return { event: null, respond: undefined }; })( collector, @@ -858,7 +858,7 @@ export async function runTransformerChain( const result = await tryCatchAsync(transformerPush, (err) => { collector.logger .scope(`transformer:${transformer.type || 'unknown'}`) - .error('Push failed', { error: err }); + .error('Push failed', errorMeta(err)); return false as const; // Stop chain on error })( collector, @@ -951,7 +951,7 @@ export async function runTransformerChain( tryCatchAsync(runTransformerChain, (err) => { collector.logger .scope('transformer:many') - .error(`many branch ${id} failed`, { error: err }); + .error(`many branch ${id} failed`, errorMeta(err)); return { event: null, respond: undefined }; })( collector, @@ -1095,7 +1095,7 @@ export async function runTransformerChain( tryCatchAsync(runTransformerChain, (err) => { collector.logger .scope('transformer:many') - .error(`many branch ${id} failed`, { error: err }); + .error(`many branch ${id} failed`, errorMeta(err)); return { event: null, respond: undefined }; })( collector, @@ -1197,7 +1197,7 @@ export async function runTransformerChain( tryCatchAsync(runTransformerChain, (err) => { collector.logger .scope('transformer:many') - .error(`many branch ${id} failed`, { error: err }); + .error(`many branch ${id} failed`, errorMeta(err)); return { event: null, respond: undefined }; })( collector, diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index 34b9242db..dc807855d 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,7 @@ # @walkeros/config +## 4.6.0 + ## 4.5.0 ## 4.4.0 diff --git a/packages/config/package.json b/packages/config/package.json index 9b109e97d..725a13245 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/config", - "version": "4.5.0", + "version": "4.6.0", "type": "module", "description": "Shared development configuration for walkerOS packages (TypeScript, ESLint, Jest, tsup)", "license": "MIT", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index a0fb1a2d1..67fc167ca 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @walkeros/core +## 4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 4fce676a9..b57af7ca4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/core", "description": "Core types and platform-agnostic utilities for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/destinations/demo/CHANGELOG.md b/packages/destinations/demo/CHANGELOG.md index bbdd89134..2ea57e872 100644 --- a/packages/destinations/demo/CHANGELOG.md +++ b/packages/destinations/demo/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/destination-demo +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/destinations/demo/package.json b/packages/destinations/demo/package.json index 63742038a..c84e345ef 100644 --- a/packages/destinations/demo/package.json +++ b/packages/destinations/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/destination-demo", "description": "Demo destination for walkerOS - logs events to console", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -40,7 +40,7 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/mcps/mcp/CHANGELOG.md b/packages/mcps/mcp/CHANGELOG.md index 0ac5c4bdc..a83a2f79d 100644 --- a/packages/mcps/mcp/CHANGELOG.md +++ b/packages/mcps/mcp/CHANGELOG.md @@ -1,5 +1,60 @@ # @walkeros/mcp +## 4.6.0 + +### Minor Changes + +- fd5949e: Tools now hand back an `appUrl` link to the screen they are talking + about. `flow_manage` get and create link the flow page, `deploy_manage` deploy + and get link the deployment, and `hub_manage` releases, threads and step + history link the release history or the step. Links are absolute, built from + the base URL the connected door reports, and omitted rather than guessed when + the address cannot be built. `deploy_manage` deploy now also passes an + explicit `projectId` through to the deploy itself, so it no longer deploys + into the default project when one was named. +- 23e9034: The `auth` tool logs in through the standard device authorization + grant and keeps the session refreshed. + + Breaking, for anyone implementing `ToolClient`: `resolveToken` is replaced by + `credentialSource`, `deleteConfig` by an async `logout` that revokes the + session before dropping it, and `requestDeviceCode`/`pollForToken` return the + CLI's device authorization types. + +### Patch Changes + +- fd5949e: `fetchHealth` and `compareContract` accept an optional base URL, so a + caller that is not the local CLI can probe its own backend instead of the one + resolved from `WALKEROS_APP_URL` and the CLI config file. Omitting it keeps + today's resolution. + + `diagnostics` passes the app URL it reports, so the contract verdict and + `appUrl.resolved` always describe the same backend. A hosted MCP no longer + probes production while naming its own deployment. + +- fd5949e: `diagnostics` reports the app URL the tool client actually talks to, + so a hosted MCP names its own deployment instead of the local CLI's default. + + `ToolClient` gains a required `appBaseUrl()` method returning that base + without a trailing slash, so a custom implementation of that interface must + add it. + +- 403ff6c: The MCP server carries `hub_manage`, which reads a flow's release + history, its rationale and the threads on it, and a read-only `frame_manage`, + which reads the frames of a measurement plan. The CLI gains the matching + programmatic calls. `ToolClient` gains eleven required methods, so a custom + implementation of that interface must add them. +- fd5949e: A tool call with no project now names how to fix it, instead of + stating that a project is missing and stopping there. Five more `flow_manage` + actions (`update`, `delete`, `duplicate`, `preview_get`, `preview_delete`) + resolve the selected project first, so they no longer fail with a raw server + error when `projectId` is omitted. +- Updated dependencies [8802281] +- Updated dependencies [fd5949e] +- Updated dependencies [403ff6c] +- Updated dependencies [23e9034] + - @walkeros/cli@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/mcps/mcp/README.md b/packages/mcps/mcp/README.md index b2e6c6fc0..e900b53a2 100644 --- a/packages/mcps/mcp/README.md +++ b/packages/mcps/mcp/README.md @@ -55,8 +55,23 @@ npm install @walkeros/mcp The server starts, registers all tools, and runs the whole local loop without any credentials. `auth` reports `{ "authenticated": false }` and the local tools -work regardless. Only the walkerOS cloud tools need a login, either through the -`auth` tool's device code flow or a `WALKEROS_TOKEN` environment variable. +work regardless. Only the walkerOS cloud tools need a credential. + +Two ways to get one: + +- **`auth` with `action: "login"`** runs the RFC 8628 device authorization + grant. It answers with a URL, you approve it in a browser you are already + signed in to, and a second call with the same `deviceCode` resumes polling + until the approval lands. The session that results refreshes itself, and it + appears in the app under Account, Connected apps, where disconnecting it takes + effect on the next call. `auth` with `action: "logout"` revokes it. +- **`WALKEROS_TOKEN`** carries an automation token (`wos_pat_...`) minted in the + app under Account, then Automation tokens. It is used as-is and never + refreshed, which is what a CI job or a headless server wants. + +There is no endpoint that mints a token from another token, and nothing issues +`sk-walkeros-` or `mcp-walkeros-` values any more; rows carrying them keep +verifying until they expire. ## Quick start @@ -76,7 +91,7 @@ five run locally: ## Tools -The server registers 17 tools. +The server registers 19 tools. ### Local, no account @@ -103,6 +118,8 @@ The server registers 17 tools. | `secret_manage` | Manage a flow's `$secret.` values. Write-mostly, values are never returned | | `observe_session` | Start, inspect, or stop an Observe session, a time-boxed window on one running flow | | `observe_journeys` | Read the assembled journeys for an observed flow, each event traced across web and server | +| `hub_manage` | Read a flow's release history and the reasoning behind it, and add to the discussion | +| `frame_manage` | Read the frames of a measurement plan, the named rectangles and the marks inside them | | `feedback` | Send feedback about walkerOS | ## Resources @@ -131,11 +148,11 @@ Read these before writing a configuration by hand. ## Environment variables -| Variable | Required | Default | Purpose | -| --------------------- | -------- | ------------------------- | ----------------------------------------------------- | -| `WALKEROS_TOKEN` | No | none | Bearer token, an alternative to the `auth` tool login | -| `WALKEROS_PROJECT_ID` | No | none | Active project ID (`proj_...`) | -| `WALKEROS_APP_URL` | No | `https://app.walkeros.io` | Base URL override | +| Variable | Required | Default | Purpose | +| --------------------- | -------- | ------------------------- | ------------------------------------------------------------------------- | +| `WALKEROS_TOKEN` | No | none | Automation token (`wos_pat_...`), an alternative to the `auth` tool login | +| `WALKEROS_PROJECT_ID` | No | none | Active project ID (`proj_...`) | +| `WALKEROS_APP_URL` | No | `https://app.walkeros.io` | Base URL override | ## Programmatic usage diff --git a/packages/mcps/mcp/package.json b/packages/mcps/mcp/package.json index 5449777d7..00dcf8daa 100644 --- a/packages/mcps/mcp/package.json +++ b/packages/mcps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/mcp", - "version": "4.5.0", + "version": "4.6.0", "description": "MCP server for walkerOS flow development - discover packages, scaffold configs, validate, bundle, simulate, and test event pipelines", "license": "MIT", "type": "module", @@ -35,15 +35,15 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/cli": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/cli": "4.6.0", + "@walkeros/core": "4.6.0" }, "peerDependencies": { "zod": "^4.0" }, "devDependencies": { "@types/node": "^25.9.1", - "@walkeros/config": "4.5.0" + "@walkeros/config": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/mcps/mcp/server.json b/packages/mcps/mcp/server.json index 098b5b42d..8d3216c25 100644 --- a/packages/mcps/mcp/server.json +++ b/packages/mcps/mcp/server.json @@ -22,7 +22,7 @@ "environmentVariables": [ { "name": "WALKEROS_TOKEN", - "description": "walkerOS API token. Optional, the local tools work without it and the auth tool can log in instead.", + "description": "walkerOS automation token (wos_pat_...) from Account, Automation tokens. Optional, the local tools work without it and the auth tool can log in instead.", "isRequired": false, "isSecret": true }, diff --git a/packages/mcps/mcp/src/__tests__/action-schema-sync.test.ts b/packages/mcps/mcp/src/__tests__/action-schema-sync.test.ts index 2cdab7583..18e426302 100644 --- a/packages/mcps/mcp/src/__tests__/action-schema-sync.test.ts +++ b/packages/mcps/mcp/src/__tests__/action-schema-sync.test.ts @@ -58,6 +58,7 @@ import { DEPLOY_MANAGE_REQUIREMENTS, PROJECT_MANAGE_REQUIREMENTS, SECRET_MANAGE_REQUIREMENTS, + FRAME_MANAGE_REQUIREMENTS, } from '../action-requirements.js'; import type { ActionRequirementMap, @@ -67,6 +68,7 @@ import { createFlowManageToolSpec } from '../tools/flow-manage.js'; import { createDeployManageToolSpec } from '../tools/deploy-manage.js'; import { createProjectManageToolSpec } from '../tools/project-manage.js'; import { createSecretManageToolSpec } from '../tools/secret-manage.js'; +import { createFrameManageToolSpec } from '../tools/frame-manage.js'; import { createFlowSimulateToolSpec } from '../tools/simulate.js'; import type { ToolSpec } from '../tool-spec.js'; import { stubClient } from './support/stub-client.js'; @@ -146,6 +148,15 @@ const mapDrivenTools: MapDrivenTool[] = [ secretId: 'sec_1', }, }, + { + name: 'frame_manage', + spec: createFrameManageToolSpec(stubClient()), + map: FRAME_MANAGE_REQUIREMENTS, + satisfied: { + pageKey: 'https://shop.example/cart', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + }, + }, ]; /** Build a base input that satisfies every map-required param of an action, so diff --git a/packages/mcps/mcp/src/__tests__/base-url.test.ts b/packages/mcps/mcp/src/__tests__/base-url.test.ts new file mode 100644 index 000000000..7b625cd84 --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/base-url.test.ts @@ -0,0 +1,71 @@ +import { normalizeBaseUrl } from '../base-url.js'; + +/** + * The base URL is user-settable through `WALKEROS_APP_URL` and the CLI config + * file, and every emitted deep link is built by appending a path to it. A shape + * that survives normalization becomes a wrong link in somebody's transcript, + * which is why these cases are about shapes rather than about one bug. + */ +describe('normalizeBaseUrl', () => { + it.each([ + ['https://app.example.com', 'https://app.example.com'], + ['https://app.example.com/', 'https://app.example.com'], + ['https://app.example.com///', 'https://app.example.com'], + ])('trims trailing slashes: %s', (input, expected) => { + expect(normalizeBaseUrl(input)).toBe(expected); + }); + + /** + * The case this file was added for. A path appended to a query or a fragment + * lands inside it, so the link points at nothing. + */ + it.each([ + ['https://app.example.com?foo=bar', 'https://app.example.com'], + ['https://app.example.com/?foo=bar', 'https://app.example.com'], + ['https://app.example.com#section', 'https://app.example.com'], + ['https://app.example.com/?foo=bar#section', 'https://app.example.com'], + ])('drops a query and a fragment: %s', (input, expected) => { + expect(normalizeBaseUrl(input)).toBe(expected); + }); + + it('appending a path to the result stays on the path', () => { + const base = normalizeBaseUrl('https://app.example.com?foo=bar'); + expect(`${base}/projects/proj_1/flows/flw_1`).toBe( + 'https://app.example.com/projects/proj_1/flows/flw_1', + ); + }); + + /** + * An app mounted under a subpath is a real deployment. Dropping the path + * would break every link for those users instead of fixing one. + */ + it.each([ + ['https://example.com/walkeros', 'https://example.com/walkeros'], + ['https://example.com/walkeros/', 'https://example.com/walkeros'], + ['https://example.com/walkeros/?a=b', 'https://example.com/walkeros'], + ])('keeps a base path: %s', (input, expected) => { + expect(normalizeBaseUrl(input)).toBe(expected); + }); + + it('keeps an explicit port', () => { + expect(normalizeBaseUrl('http://localhost:3000/')).toBe( + 'http://localhost:3000', + ); + }); + + /** + * Never throws: `diagnostics` calls this to report a misconfigured app URL, + * so a normalizer that threw would take out the tool that names the problem. + */ + it.each([ + ['not-a-url', 'not-a-url'], + ['not-a-url/', 'not-a-url'], + ['', ''], + ])( + 'hands back an unparseable string rather than throwing: %s', + (input, expected) => { + expect(() => normalizeBaseUrl(input)).not.toThrow(); + expect(normalizeBaseUrl(input)).toBe(expected); + }, + ); +}); diff --git a/packages/mcps/mcp/src/__tests__/create-tool-handlers.test.ts b/packages/mcps/mcp/src/__tests__/create-tool-handlers.test.ts index a7a9e35c9..8de2ef7e0 100644 --- a/packages/mcps/mcp/src/__tests__/create-tool-handlers.test.ts +++ b/packages/mcps/mcp/src/__tests__/create-tool-handlers.test.ts @@ -6,13 +6,24 @@ jest.mock('@walkeros/cli', () => ({ examples: jest.fn(), flowLoad: jest.fn(), loadFlow: jest.fn(), + listReleases: jest.fn(), + getRelease: jest.fn(), + listStepHistory: jest.fn(), + setReleaseRationale: jest.fn(), + listThreads: jest.fn(), + createThread: jest.fn(), + addThreadMessage: jest.fn(), + listKnowledge: jest.fn(), + listFrames: jest.fn(), + listPageFrames: jest.fn(), + getFrame: jest.fn(), })); import { createToolHandlers } from '../index.js'; import { stubClient } from './support/stub-client.js'; describe('createToolHandlers', () => { - it('returns specs for all 17 tools keyed by name', () => { + it('returns specs for all 19 tools keyed by name', () => { const specs = createToolHandlers(stubClient()); expect(Object.keys(specs).sort()).toEqual( [ @@ -27,6 +38,8 @@ describe('createToolHandlers', () => { 'flow_push', 'flow_simulate', 'flow_validate', + 'frame_manage', + 'hub_manage', 'observe_journeys', 'observe_session', 'package_get', diff --git a/packages/mcps/mcp/src/__tests__/diagnostics.test.ts b/packages/mcps/mcp/src/__tests__/diagnostics.test.ts index 4f2279dfd..a07ec09ce 100644 --- a/packages/mcps/mcp/src/__tests__/diagnostics.test.ts +++ b/packages/mcps/mcp/src/__tests__/diagnostics.test.ts @@ -1,16 +1,17 @@ import './support/version.js'; // Mock @walkeros/cli to keep its ESM-only transitive deps (chalk) out of the -// transform path. The diagnostics tool reads VERSION + resolveAppUrl from it; -// resolveAppUrl mirrors the real env-vs-default precedence so the appUrl -// assertions exercise real provenance logic. +// transform path. The diagnostics tool reads VERSION and compareContract from +// it; the app URL no longer comes from the CLI at all, it comes from the +// client, which `localDoor` below stands in for. const MOCK_CLI_VERSION = '5.4.3-test'; const mockCompareContract = jest.fn(); jest.mock('@walkeros/cli', () => ({ VERSION: '5.4.3-test', - resolveAppUrl: () => - process.env.WALKEROS_APP_URL ?? 'https://app.walkeros.io', - compareContract: () => mockCompareContract(), + // Forwards its input: the tool has to hand the probe the same backend it + // prints as appUrl.resolved, and a mock that swallowed the argument could + // not tell a threaded URL from none at all. + compareContract: (input: unknown) => mockCompareContract(input), })); import { createRequire } from 'module'; @@ -18,6 +19,8 @@ import { dirname, join } from 'path'; import { readFileSync } from 'fs'; import { createDiagnosticsToolSpec } from '../tools/diagnostics.js'; import { stubClient } from './support/stub-client.js'; +import type { ToolClient } from '../tool-client.js'; +import { normalizeBaseUrl } from '../base-url.js'; import { clearCatalogCache, fetchCatalog } from '../catalog.js'; import { SERVER_INSTRUCTIONS } from '../instructions.js'; @@ -50,8 +53,25 @@ interface DiagnosticsResult { _hints?: { next?: string[]; warnings?: string[] }; } +/** + * A stub standing in for the LOCAL door: its `appBaseUrl` resolves the same + * env-then-default chain the CLI-backed client hands the tool, normalized the + * same way, so the appUrl assertions exercise real provenance rather than a + * constant. `HttpToolClient`'s own delegation is pinned in + * `http-tool-client.test.ts`. + */ +function localDoor(overrides: Partial = {}): ToolClient { + return stubClient({ + appBaseUrl: () => + normalizeBaseUrl( + process.env.WALKEROS_APP_URL ?? 'https://app.walkeros.io', + ), + ...overrides, + }); +} + async function runDiagnostics( - client = stubClient(), + client = localDoor(), packageVersion = '7.7.7', ): Promise { const spec = createDiagnosticsToolSpec(client, packageVersion); @@ -117,8 +137,41 @@ describe('diagnostics tool', () => { expect(out.appUrl.resolved).toBe('https://app.test'); }); + it('keeps the env provenance for a slashed WALKEROS_APP_URL', async () => { + // A valid URL may carry a trailing slash, and the interface promises a + // base without one. Comparing the raw env value against the normalized + // base would report `default` and warn that the variable did not set the + // URL, when it plainly did. + process.env.WALKEROS_APP_URL = 'https://stage.app.walkeros.io/'; + const out = await runDiagnostics(); + expect(out.appUrl.resolved).toBe('https://stage.app.walkeros.io'); + expect(out.appUrl.source).toBe('env'); + expect(out._hints?.warnings ?? []).not.toContainEqual( + expect.stringContaining('WALKEROS_APP_URL'), + ); + }); + + it('reports the app URL the client names, not the local CLI resolution', async () => { + // The hosted door runs inside the app it reports and ignores the local + // env var entirely. Resolving the URL in the tool would name the wrong + // backend here, which is the whole point of the client seam. + process.env.WALKEROS_APP_URL = 'https://app.test'; + const out = await runDiagnostics( + stubClient({ appBaseUrl: () => 'https://stage.app.walkeros.io' }), + ); + expect(out.appUrl.resolved).toBe('https://stage.app.walkeros.io'); + }); + + it('withholds the env provenance from a client that did not use the env var', async () => { + process.env.WALKEROS_APP_URL = 'https://app.test'; + const out = await runDiagnostics( + stubClient({ appBaseUrl: () => 'https://stage.app.walkeros.io' }), + ); + expect(out.appUrl.source).toBe('default'); + }); + it('reports app.reachable true when checkHealth resolves reachable', async () => { - const client = stubClient({ + const client = localDoor({ checkHealth: async () => ({ reachable: true, status: 'ok' }), }); const out = await runDiagnostics(client); @@ -127,7 +180,7 @@ describe('diagnostics tool', () => { }); it('reports app.reachable false and still returns when checkHealth rejects', async () => { - const client = stubClient({ + const client = localDoor({ checkHealth: async () => { throw new Error('network down'); }, @@ -142,7 +195,7 @@ describe('diagnostics tool', () => { it('reports app.reachable false and still returns when checkHealth is absent', async () => { // An external ToolClient implementation may omit the optional checkHealth // method; diagnostics must degrade to reachable: false without throwing. - const { checkHealth: _omit, ...withoutHealth } = stubClient(); + const { checkHealth: _omit, ...withoutHealth } = localDoor(); const out = await runDiagnostics(withoutHealth); expect(out.app.reachable).toBe(false); expect( @@ -167,6 +220,21 @@ describe('diagnostics tool', () => { expect(out.contract.action).toBe('upgrade @walkeros/cli to >= 1.3.0'); }); + it('probes the contract at the app URL it reports, not a self-resolved one', async () => { + // The hosted door is served on its own URL and has no CLI config file, so + // a probe left to resolve itself would read the local machine and report a + // verdict about production. Two answers in one response must not describe + // two different backends. + process.env.WALKEROS_APP_URL = 'https://app.test'; + const out = await runDiagnostics( + stubClient({ appBaseUrl: () => 'https://stage.app.walkeros.io' }), + ); + expect(mockCompareContract).toHaveBeenCalledWith({ + baseUrl: 'https://stage.app.walkeros.io', + }); + expect(out.appUrl.resolved).toBe('https://stage.app.walkeros.io'); + }); + it('degrades the verdict to unknown when compareContract reports unreachable', async () => { mockCompareContract.mockResolvedValue({ verdict: 'unknown', diff --git a/packages/mcps/mcp/src/__tests__/fixtures/mcp-surface-parity.json b/packages/mcps/mcp/src/__tests__/fixtures/mcp-surface-parity.json index ebd02b80c..1aa30a79c 100644 --- a/packages/mcps/mcp/src/__tests__/fixtures/mcp-surface-parity.json +++ b/packages/mcps/mcp/src/__tests__/fixtures/mcp-surface-parity.json @@ -12,6 +12,8 @@ "flow_push", "flow_simulate", "flow_validate", + "frame_manage", + "hub_manage", "observe_journeys", "observe_session", "package_get", @@ -19,6 +21,20 @@ "project_manage", "secret_manage" ], + "links": { + "$comment": "Deep links the tools emit, under the response key named here. They land in chat transcripts and notes nobody can reach back into, so a route rename or a view-key rename in the app has to fail a test at edit time instead of rotting in someone's history. The builders live in walkerOS packages/mcps/mcp/src/links.ts; the app checks these against its real routes and its url-state registry in src/lib/mcp/__tests__/link-routes.test.ts. A route template names its params as the BUILDER names them, and the app's folder may spell the same segment differently ([id] for {flowId}), so a template is matched by shape and position, never by param name.", + "responseKey": "appUrl", + "viewParamKey": "view", + "routes": { + "flowPage": "/projects/{projectId}/flows/{flowId}", + "deploymentPage": "/projects/{projectId}/deployments/{deploymentId}" + }, + "views": { + "contract": [], + "releases": [], + "step": ["flow", "step"] + } + }, "observeSession": { "name": "observe_session", "title": "Observe Session", @@ -103,5 +119,252 @@ "stopEnded": ["ENDED"], "stopNoWindow": ["NO_WINDOW"] } + }, + "hubManage": { + "name": "hub_manage", + "title": "Release History and Rationale", + "description": "Read a flow’s release history and the reasoning behind it: what each release changed, and why. Actions: releases (lean index of a flow’s releases, newest first, no snapshots), release_get (one release in full: its rationale plus a server-computed diff against the release before it), step_history (which releases added, changed, or removed one step), rationale_set (write the human rationale for one release; additive, nothing is ever deleted), threads (the discussion anchored to one release, or every open discussion on the flow), note_add (add a message to a thread, or open a new one on a release), knowledge (read what people wrote on the marks of a page in Tag Mode: one page, one mark, or the whole project). Threads are resolved by a person in the app, never here: this tool can only add to a discussion. Knowledge is read-only here for the same reason, and is addressed by frame and mark rather than by flow. Steps are addressed as \"type.name\", the same form flow_simulate takes, for example \"destination.ga4\", \"transformer.router\", \"source.browser\", \"store.session\", or \"contract.checkout\" for a contract entry. This tool carries history only: use flow_manage for the current config and package_get for step schemas.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "releases", + "release_get", + "step_history", + "rationale_set", + "threads", + "note_add", + "knowledge" + ], + "description": "Which part of the release history to read or write" + }, + "projectId": { + "description": "Project ID. Optional: falls back to the default project when omitted.", + "type": "string" + }, + "flowId": { + "description": "Flow ID (flow_...). Required for every action except \"knowledge\", which hangs on a page rather than a flow and refuses this field.", + "type": "string" + }, + "versionId": { + "description": "Release version ID (ver_...) from action \"releases\". Addresses one release for release_get and rationale_set. Pass this or versionNumber.", + "type": "string", + "minLength": 1 + }, + "versionNumber": { + "description": "Spine release number for this flow, the `versionNumber` field of action \"releases\". Alternative to versionId. Not the same as a row's deploymentAttempt.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "description": "Step key as \"type.name\", e.g. \"destination.ga4\" or \"contract.checkout\". Required for step_history.", + "type": "string" + }, + "flow": { + "description": "Named flow inside the config, e.g. \"web\" or \"server\". Optional for step_history: omit to scan every named flow. Ignored for contract steps, which are top-level.", + "type": "string" + }, + "text": { + "description": "The text to write (1-4000 chars). Required for rationale_set and note_add. As rationale it replaces the note already on the release and never touches the machine summary; as a note it is appended to a thread and nothing is ever replaced.", + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "limit": { + "description": "Page size. Releases to list for action \"releases\" (max 100), or releases to SCAN for step_history (max 50). For step_history this bounds releases, not entries: a step present in several named flows yields one entry per flow per release, and the scan stops at 200 entries with entriesTruncated set. Narrow with \"flow\" to avoid that. For threads it bounds threads, and a read that carries messages is held to 20 of them. Knowledge entries are bounded the same way.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "offset": { + "description": "Releases to skip. Action \"releases\" only.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "anchorType": { + "description": "What a thread hangs on. Defaults to \"release\", the only anchor the app writes today. Pair it with anchorKey; a key means a different thing under each type.", + "type": "string", + "enum": ["step", "entity_action", "release", "contract", "tag"] + }, + "anchorKey": { + "description": "What the anchor addresses within its type: a release version ID (ver_...) for \"release\", a \"type.name\" step key for \"step\". For a release you can pass versionId or versionNumber instead. Omit entirely on action \"threads\" to read every thread on the flow.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "anchorLabel": { + "description": "How the anchor reads on screen, stored once when a thread is opened so a later rename leaves it readable. Derived for a release (\"v14\"); pass it only when opening a thread on another anchor type.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "threadId": { + "description": "Thread ID (thr_...) from action \"threads\". Pass it to note_add to reply in that thread; omit it to open a new thread on the anchor.", + "type": "string", + "minLength": 1 + }, + "status": { + "description": "Read only threads in this state. Action \"threads\" only; omit for both.", + "type": "string", + "enum": ["open", "resolved"] + }, + "pageKey": { + "description": "The page a note was left on, as Tag Mode addressed it, usually the page URL. Narrows action \"knowledge\" to every frame that page holds, at any depth; omit it to read the whole project.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "frameId": { + "description": "One frame (frm_...), the named rectangle a note hangs on. Action \"knowledge\" only. Narrower than pageKey, since a page holds several frames.", + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$" + }, + "markId": { + "description": "One mark within \"frameId\". Action \"knowledge\" only, and refused without frameId, since a mark id alone addresses nothing. Naming a mark is also what attaches the message bodies.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["action"], + "additionalProperties": false + }, + "denialHint": "The \"hub\" feature is not enabled for this project. Tell the person that \"hub\" needs a plan or project entitlement that unlocks it, which is changed in the app, not through this tool.", + "notFoundHint": "Use action \"releases\" to find version ids and action \"threads\" to find thread ids.", + "hints": { + "CONFIRM_INDEX": "Use action \"releases\" to confirm it appears in the index.", + "ENTRY_NAMES_FLOW": "An entry names the flow it was written against in flowId, and validity says which release was live at the time.", + "KEEP_ONE_THREAD": "Pass its threadId back to action \"note_add\" to keep the conversation in one place instead of opening another thread.", + "KNOWLEDGE_INDEX": "This is an index: message bodies are omitted. Pass markId with frameId to read one mark in full.", + "KNOWLEDGE_PAGE_CAPPED": "More knowledge matches than was returned. Narrow with pageKey, then frameId, then markId, rather than treating this as everything that was written.", + "KNOWLEDGE_READ_ONLY": "This tool only reads knowledge. Answering a note, and settling it, are done by a person in the app.", + "MASKED_ONLY": "The diff is empty because the only changes are inside masked values. Say that the change is not visible here rather than that nothing changed.", + "MESSAGES_TRUNCATED": "A thread here is marked hasMoreMessages: only its newest messages were returned. Say the discussion is longer than what you read rather than summarizing it as complete.", + "MESSAGE_VISIBLE": "The message is now visible in this thread in the app.", + "NOTHING_DISCUSSED": "Nothing is being discussed on this flow. Use action \"note_add\" with a versionId to start a thread on a release.", + "NOTHING_WRITTEN": "Nothing has been written here. Notes are left on the page in Tag Mode, not through this tool.", + "NO_MATCH": "No scanned release touched this step. Check the step key against knownSteps, or raise limit to scan further back.", + "NO_THREAD_ON_ANCHOR": "No thread hangs on this anchor yet. Use action \"note_add\" to open one.", + "OPEN_RELEASE": "Use action \"release_get\" on one of these versionIds to read the full diff and rationale.", + "RATIONALE_VISIBLE": "The rationale is now visible on this release in the app.", + "READ_BACK": "Use action \"threads\" to read the discussion back.", + "READ_FRAME": "Use frame_manage action \"get\" with the frameId to read the frame and its marks.", + "RELEASE_GET": "Use action \"release_get\" with a versionId or versionNumber to read one release in full, with its diff.", + "REPLY_OR_OPEN": "Use action \"note_add\" with a threadId to reply in one of these threads, or without one to open another.", + "RESOLVE_IN_APP": "Resolving a thread into a release is done by a person in the app, not through this tool.", + "ROWS_ARE_DEPLOYMENTS": "Rows are deployments, not releases: the same versionId on several rows is a redeploy of identical content, so count distinct versionId values, and note that total counts deployments.", + "SCAN_CAPPED": "The scan stopped at the entry cap, so older releases were not compared. Narrow with \"flow\" to see the whole history of one occurrence.", + "STAYS_RESOLVED": "This thread is resolved and stayed resolved: a reply never retracts the release link.", + "STEP_HISTORY": "Use action \"step_history\" to see which releases touched one step.", + "THREADS_INDEX": "This is an index: message bodies are omitted. Pass a versionId, or anchorType with anchorKey, to read one discussion in full.", + "THREADS_PAGE_CAPPED": "More threads match than were returned (the page is capped at 20 when messages are attached). Narrow with anchorType and anchorKey, or with status, rather than treating this as the complete list.", + "THREAD_OPEN": "The thread is now open on this anchor in the app.", + "TRACE_STEP": "Use action \"step_history\" to trace one step across releases.", + "WRITE_RATIONALE": "This release has no human rationale. Read the diff, then use action \"rationale_set\" to record why it changed." + }, + "hintOrder": { + "releases": ["RELEASE_GET", "ROWS_ARE_DEPLOYMENTS", "STEP_HISTORY"], + "releaseGetMaskedOnly": ["MASKED_ONLY"], + "releaseGetWithRationale": ["TRACE_STEP"], + "releaseGetNoRationale": ["WRITE_RATIONALE"], + "stepHistoryCapped": ["SCAN_CAPPED"], + "stepHistoryEmpty": ["NO_MATCH"], + "stepHistoryMatched": ["OPEN_RELEASE"], + "rationaleSet": ["RATIONALE_VISIBLE", "CONFIRM_INDEX"], + "threadsIndexEmpty": ["NOTHING_DISCUSSED"], + "threadsAnchorEmpty": ["NO_THREAD_ON_ANCHOR"], + "threadsIndexCapped": ["THREADS_PAGE_CAPPED", "THREADS_INDEX"], + "threadsAnchorTruncated": [ + "MESSAGES_TRUNCATED", + "REPLY_OR_OPEN", + "RESOLVE_IN_APP" + ], + "noteAddReplyOpen": ["MESSAGE_VISIBLE", "READ_BACK"], + "noteAddReplyResolved": ["MESSAGE_VISIBLE", "STAYS_RESOLVED"], + "noteAddOpen": ["THREAD_OPEN", "KEEP_ONE_THREAD"], + "knowledgeEmpty": ["NOTHING_WRITTEN"], + "knowledgeIndexCapped": [ + "KNOWLEDGE_PAGE_CAPPED", + "KNOWLEDGE_INDEX", + "ENTRY_NAMES_FLOW", + "KNOWLEDGE_READ_ONLY", + "READ_FRAME" + ], + "knowledgeMarkTruncated": [ + "MESSAGES_TRUNCATED", + "ENTRY_NAMES_FLOW", + "KNOWLEDGE_READ_ONLY", + "READ_FRAME" + ] + } + }, + "frameManage": { + "name": "frame_manage", + "title": "Frames", + "description": "Read the frames of a measurement plan: named rectangles with marks inside them, drawn in Tag Mode or in the app. Actions: list (every frame of the project, without marks), page (the frames of one page at any depth, with marks), get (one frame with its marks). Read-only: frames are drawn and edited in Tag Mode or the app, never here. A frame name is documentation; the marks inside it carry the meaning. A frame that extends another stores only what it adds. Use hub_manage action \"knowledge\" with a frameId or markId to read what people wrote on a frame. A markId is an id read from the marks of a frame here: mark ids come back literal so they can be passed straight back, while the text around them is wrapped as data. An entity action is an object carrying its id beside the raw attribute text, because the id is the address and the raw text is not.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "page", "get"], + "description": "list the project’s frames, read one page with marks, or read one frame" + }, + "projectId": { + "description": "Project ID. Optional: falls back to the default project when omitted.", + "type": "string" + }, + "pageKey": { + "description": "The page as its frames address it (the `source.key` of a page frame, usually the page URL without query). Required for page.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "frameId": { + "description": "Frame ID (frm_...). Required for get. Use action \"list\" or \"page\" to find one.", + "type": "string", + "pattern": "^frm_[A-Za-z0-9_-]{21}$" + } + }, + "required": ["action"], + "additionalProperties": false + }, + "denialHint": "The \"frames\" feature is not enabled for this project. Tell the person that \"frames\" needs a plan or project entitlement that unlocks it, which is changed in the app, not through this tool.", + "notFoundHint": "Use action \"list\" or \"page\" to find frame ids.", + "hints": { + "EXTENDS_BASE": "This frame extends another and stores only what it adds; read the base frame (extends) for the rest.", + "MARK_SPACE": "Marks are in their frame’s own 0..1 space; a child frame sits inside its parent through placements[].rect.", + "NAMES_ARE_DOCUMENTATION": "Frame names are documentation; the marks inside a frame carry the meaning.", + "NONE_ON_PAGE": "No frames on this page. Check the pageKey against the source.key values from action \"list\".", + "NONE_YET": "This project has no frames yet. Frames are drawn in Tag Mode or the app, not through this tool.", + "OPEN_PAGE_OR_GET": "Use action \"page\" with a pageKey (a frame’s source.key) to read a page with marks, or action \"get\" with a frameId.", + "READ_KNOWLEDGE": "Use hub_manage action \"knowledge\" with frameId (and markId) to read what people wrote here. A markId is an id from these marks, and an entity action is addressed by its own id, never by its raw text." + }, + "hintOrder": { + "list": ["OPEN_PAGE_OR_GET", "NAMES_ARE_DOCUMENTATION"], + "listEmpty": ["NONE_YET"], + "page": ["MARK_SPACE", "READ_KNOWLEDGE"], + "pageEmpty": ["NONE_ON_PAGE"], + "getVariation": ["EXTENDS_BASE", "READ_KNOWLEDGE"], + "getBase": ["READ_KNOWLEDGE"] + } } } diff --git a/packages/mcps/mcp/src/__tests__/http-tool-client.test.ts b/packages/mcps/mcp/src/__tests__/http-tool-client.test.ts index 905a9fb7d..8c1ced3d7 100644 --- a/packages/mcps/mcp/src/__tests__/http-tool-client.test.ts +++ b/packages/mcps/mcp/src/__tests__/http-tool-client.test.ts @@ -30,12 +30,23 @@ jest.mock('@walkeros/cli', () => ({ startObserveSession: jest.fn(), getObserveSession: jest.fn(), endObserveSession: jest.fn(), - requestDeviceCode: jest.fn(), - pollForToken: jest.fn(), + listReleases: jest.fn(), + getRelease: jest.fn(), + listStepHistory: jest.fn(), + setReleaseRationale: jest.fn(), + listThreads: jest.fn(), + createThread: jest.fn(), + addThreadMessage: jest.fn(), + listKnowledge: jest.fn(), + listFrames: jest.fn(), + listPageFrames: jest.fn(), + getFrame: jest.fn(), + startDeviceAuthorization: jest.fn(), + completeDeviceLogin: jest.fn(), whoami: jest.fn(), - resolveToken: jest.fn(), + credentialSource: jest.fn(), resolveAppUrl: jest.fn(), - deleteConfig: jest.fn(), + logout: jest.fn(), feedback: jest.fn(), getFeedbackPreference: jest.fn(), setFeedbackPreference: jest.fn(), @@ -43,7 +54,7 @@ jest.mock('@walkeros/cli', () => ({ import * as cli from '@walkeros/cli'; import { HttpToolClient } from '../http-tool-client.js'; -import type { ObserveSessionResult } from '../tool-client.js'; +import type { HubThreadWire, ObserveSessionResult } from '../tool-client.js'; const observeSession: ObserveSessionResult = { id: 'ses_1', @@ -237,8 +248,8 @@ describe('HttpToolClient', () => { }); it('checkHealth returns reachable true with NO token set (tokenless probe)', async () => { - // resolveToken returns null → logged out; checkHealth must not require auth. - (cli.resolveToken as jest.Mock).mockReturnValue(null); + // credentialSource returns null → logged out; checkHealth must not require auth. + (cli.credentialSource as jest.Mock).mockReturnValue(null); (cli.resolveAppUrl as jest.Mock).mockReturnValue('https://app.test'); const mockFetch = jest .fn() @@ -266,15 +277,285 @@ describe('HttpToolClient', () => { }); it('delegates sync config helpers without awaiting', () => { - (cli.resolveToken as jest.Mock).mockReturnValue({ - token: 'tok_abc', - source: 'env', - }); - (cli.deleteConfig as jest.Mock).mockReturnValue(true); + (cli.credentialSource as jest.Mock).mockReturnValue('env'); (cli.getDefaultProject as jest.Mock).mockReturnValue('proj_1'); const client = new HttpToolClient(); - expect(client.resolveToken()).toEqual({ token: 'tok_abc', source: 'env' }); - expect(client.deleteConfig()).toBe(true); + expect(client.credentialSource()).toBe('env'); expect(client.getDefaultProject()).toBe('proj_1'); }); + + it('starts a device authorization against the resolved app URL', async () => { + (cli.resolveAppUrl as jest.Mock).mockReturnValue('https://app.test'); + (cli.startDeviceAuthorization as jest.Mock).mockResolvedValue({ + deviceCode: 'dc_1', + }); + + await new HttpToolClient().requestDeviceCode(); + + expect(cli.startDeviceAuthorization).toHaveBeenCalledWith( + 'https://app.test', + ); + }); + + it('resumes a device authorization through completeDeviceLogin', async () => { + (cli.completeDeviceLogin as jest.Mock).mockResolvedValue({ + status: 'pending', + }); + + const result = await new HttpToolClient().pollForToken('dc_1', { + timeoutMs: 1000, + }); + + expect(cli.completeDeviceLogin).toHaveBeenCalledWith('dc_1', { + timeoutMs: 1000, + }); + expect(result).toEqual({ status: 'pending' }); + }); + + it('logs out through the revoking cli logout, not a bare config delete', async () => { + (cli.logout as jest.Mock).mockResolvedValue({ deleted: true }); + + await expect(new HttpToolClient().logout()).resolves.toEqual({ + deleted: true, + }); + expect(cli.logout).toHaveBeenCalled(); + }); + + it('names the app through the same resolution every other method uses', () => { + (cli.resolveAppUrl as jest.Mock).mockReturnValue( + 'https://stage.app.walkeros.io', + ); + + expect(new HttpToolClient().appBaseUrl()).toBe( + 'https://stage.app.walkeros.io', + ); + expect(cli.resolveAppUrl).toHaveBeenCalled(); + }); + + it('strips a trailing slash the env var or config file may carry', () => { + // A base a caller concatenates a path onto has to have one shape, and + // neither WALKEROS_APP_URL nor the CLI config file promises it. + (cli.resolveAppUrl as jest.Mock).mockReturnValue( + 'https://stage.app.walkeros.io/', + ); + + expect(new HttpToolClient().appBaseUrl()).toBe( + 'https://stage.app.walkeros.io', + ); + }); +}); + +describe('HttpToolClient hub and frames delegation', () => { + beforeEach(() => jest.clearAllMocks()); + + it('delegates listReleases', async () => { + const wire = { releases: [], total: 0, limit: 20, offset: 0 }; + jest.mocked(cli.listReleases).mockResolvedValue(wire); + await expect( + new HttpToolClient().listReleases({ + projectId: 'proj_1', + flowId: 'flow_1', + limit: 5, + }), + ).resolves.toEqual(wire); + expect(cli.listReleases).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + limit: 5, + }); + }); + + it('delegates getRelease', async () => { + const wire = { + versionId: 'ver_1', + versionNumber: 3, + contentHash: null, + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + rationale: null, + diff: null, + }; + jest.mocked(cli.getRelease).mockResolvedValue(wire); + await expect( + new HttpToolClient().getRelease({ + projectId: 'proj_1', + flowId: 'flow_1', + ref: { versionNumber: 3 }, + }), + ).resolves.toEqual(wire); + expect(cli.getRelease).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + ref: { versionNumber: 3 }, + }); + }); + + it('delegates listStepHistory', async () => { + const wire = { + step: 'destination.ga4', + flow: null, + entries: [], + scanned: 0, + truncated: false, + entriesTruncated: false, + }; + jest.mocked(cli.listStepHistory).mockResolvedValue(wire); + await expect( + new HttpToolClient().listStepHistory({ + projectId: 'proj_1', + flowId: 'flow_1', + step: 'destination.ga4', + }), + ).resolves.toEqual(wire); + expect(cli.listStepHistory).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + step: 'destination.ga4', + }); + }); + + it('delegates setReleaseRationale', async () => { + const wire = { + versionId: 'ver_1', + humanText: 'why', + generatedSummary: null, + author: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + }; + jest.mocked(cli.setReleaseRationale).mockResolvedValue(wire); + await expect( + new HttpToolClient().setReleaseRationale({ + projectId: 'proj_1', + flowId: 'flow_1', + versionId: 'ver_1', + text: 'why', + }), + ).resolves.toEqual(wire); + expect(cli.setReleaseRationale).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + versionId: 'ver_1', + text: 'why', + }); + }); + + it('delegates listThreads', async () => { + const wire = { threads: [], hasMoreThreads: false }; + jest.mocked(cli.listThreads).mockResolvedValue(wire); + await expect( + new HttpToolClient().listThreads({ + projectId: 'proj_1', + flowId: 'flow_1', + includeMessages: false, + }), + ).resolves.toEqual(wire); + expect(cli.listThreads).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + includeMessages: false, + }); + }); + + it('delegates createThread and addThreadMessage', async () => { + const wire: HubThreadWire = { + id: 'thr_1', + anchorType: 'release', + anchorKey: 'ver_1', + anchorLabel: 'v3', + status: 'open', + resolvedByVersionId: null, + resolvedByVersionNumber: null, + resolvedAt: null, + resolvedBy: null, + createdBy: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + messageCount: 1, + }; + jest.mocked(cli.createThread).mockResolvedValue(wire); + jest.mocked(cli.addThreadMessage).mockResolvedValue(wire); + const client = new HttpToolClient(); + await expect( + client.createThread({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'release', + anchorKey: 'ver_1', + text: 'hi', + }), + ).resolves.toEqual(wire); + await expect( + client.addThreadMessage({ + projectId: 'proj_1', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'hi', + }), + ).resolves.toEqual(wire); + expect(cli.createThread).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'release', + anchorKey: 'ver_1', + text: 'hi', + }); + expect(cli.addThreadMessage).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'hi', + }); + }); + + it('delegates listKnowledge', async () => { + const wire = { entries: [], hasMoreEntries: false }; + jest.mocked(cli.listKnowledge).mockResolvedValue(wire); + await expect( + new HttpToolClient().listKnowledge({ + projectId: 'proj_1', + includeMessages: true, + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + markId: 'm1', + }), + ).resolves.toEqual(wire); + expect(cli.listKnowledge).toHaveBeenCalledWith({ + projectId: 'proj_1', + includeMessages: true, + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + markId: 'm1', + }); + }); + + it('delegates the three frame reads', async () => { + jest.mocked(cli.listFrames).mockResolvedValue({ frames: [] }); + jest.mocked(cli.listPageFrames).mockResolvedValue({ frames: [] }); + const client = new HttpToolClient(); + await expect(client.listFrames({ projectId: 'proj_1' })).resolves.toEqual({ + frames: [], + }); + await expect( + client.listPageFrames({ + projectId: 'proj_1', + pageKey: 'https://shop.example/', + }), + ).resolves.toEqual({ frames: [] }); + expect(cli.listFrames).toHaveBeenCalledWith({ projectId: 'proj_1' }); + expect(cli.listPageFrames).toHaveBeenCalledWith({ + projectId: 'proj_1', + pageKey: 'https://shop.example/', + }); + + // The client adds nothing to a failure: the tool layer reads the code. + const refused = Object.assign(new Error('Frame not found'), { + code: 'NOT_FOUND', + }); + jest.mocked(cli.getFrame).mockRejectedValue(refused); + await expect( + client.getFrame({ + projectId: 'proj_1', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + }), + ).rejects.toBe(refused); + }); }); diff --git a/packages/mcps/mcp/src/__tests__/links.test.ts b/packages/mcps/mcp/src/__tests__/links.test.ts new file mode 100644 index 000000000..b6d408149 --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/links.test.ts @@ -0,0 +1,246 @@ +import { links } from '../links.js'; +import fixture from './fixtures/mcp-surface-parity.json'; + +const BASE = 'https://app.walkeros.io'; +const FLOW = { baseUrl: BASE, projectId: 'proj_1', flowId: 'flw_1' }; + +describe('links.flow', () => { + it('addresses the flow page absolutely', () => { + expect(links.flow(FLOW)).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1', + ); + }); + + it.each([['baseUrl'], ['projectId'], ['flowId']] as const)( + 'builds nothing when %s is empty', + (field) => { + expect(links.flow({ ...FLOW, [field]: '' })).toBeUndefined(); + }, + ); + + it('takes the base URL as given, without renormalizing it', () => { + // The door that answers `appBaseUrl()` owns the shape. A second opinion + // here is what would let two doors disagree about it. + expect(links.flow({ ...FLOW, baseUrl: 'http://localhost:3000' })).toBe( + 'http://localhost:3000/projects/proj_1/flows/flw_1', + ); + }); +}); + +describe('links.step', () => { + it('addresses one step by its named flow and type.name', () => { + expect(links.step({ ...FLOW, flow: 'web', step: 'destination.ga4' })).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1?view=step&flow=web&step=destination.ga4', + ); + }); + + it.each([[undefined], [null], ['']])( + 'builds nothing for a step whose flow is %p', + (flow) => { + expect( + links.step({ ...FLOW, step: 'destination.ga4', flow }), + ).toBeUndefined(); + }, + ); + + it('builds nothing when no step is named', () => { + expect(links.step({ ...FLOW, flow: 'web', step: '' })).toBeUndefined(); + }); + + it.each([['contract.checkout'], ['contract.'], ['contract']])( + 'answers %p with the contract view, which the app can actually open', + (step) => { + expect(links.step({ ...FLOW, step })).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1?view=contract', + ); + }, + ); + + it('ignores a flow passed beside a contract step', () => { + // Contract entries are top-level, so a flow beside one is a caller + // mistake. Carrying it through would build the step address the app + // refuses. + expect( + links.step({ ...FLOW, flow: 'web', step: 'contract.checkout' }), + ).toBe('https://app.walkeros.io/projects/proj_1/flows/flw_1?view=contract'); + }); + + it('escapes a flow name that would otherwise break the query string', () => { + expect( + links.step({ ...FLOW, flow: 'web&view=secrets', step: 'source.browser' }), + ).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1?view=step&flow=web%26view%3Dsecrets&step=source.browser', + ); + }); +}); + +describe('links.release', () => { + it('addresses the release history the release is listed in', () => { + expect(links.release(FLOW)).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1?view=releases', + ); + }); + + it('builds nothing without a flow to hang the view on', () => { + expect(links.release({ ...FLOW, flowId: '' })).toBeUndefined(); + }); +}); + +describe('links.thread', () => { + it('addresses the release history, where release threads are read', () => { + expect(links.thread({ ...FLOW, anchorType: 'release' })).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flw_1?view=releases', + ); + }); + + it.each([['step'], ['entity_action'], ['contract'], ['tag']])( + 'builds nothing for a %s anchor, which has no screen yet', + (anchorType) => { + expect(links.thread({ ...FLOW, anchorType })).toBeUndefined(); + }, + ); +}); + +describe('links.deployment', () => { + it('addresses the deployment page', () => { + expect( + links.deployment({ + baseUrl: BASE, + projectId: 'proj_1', + deploymentId: 'dep_1', + }), + ).toBe('https://app.walkeros.io/projects/proj_1/deployments/dep_1'); + }); + + it('does not inspect the id it is given', () => { + // Naming the `dep_...` id is the caller's job: the detail route resolves a + // slug too, but the page's live-status stream matches on the id alone. + // This builder concatenates, it does not judge. + expect( + links.deployment({ + baseUrl: BASE, + projectId: 'proj_1', + deploymentId: 'k7m2x9p4q1w8', + }), + ).toBe('https://app.walkeros.io/projects/proj_1/deployments/k7m2x9p4q1w8'); + }); + + it.each([['baseUrl'], ['projectId'], ['deploymentId']] as const)( + 'builds nothing when %s is empty', + (field) => { + const target = { + baseUrl: BASE, + projectId: 'proj_1', + deploymentId: 'dep_1', + }; + expect(links.deployment({ ...target, [field]: '' })).toBeUndefined(); + }, + ); +}); + +/** + * The `links` section of the shared surface fixture, against the builders above. + * + * WHY, when the builders are already pinned by literal strings here. The app + * commits a byte-identical copy of that fixture and walks it against its real + * routes and its url-state registry. Nothing in THIS repo read it, so a rename + * carried through `links.ts` and the literals above left the fixture stale and + * every walkerOS suite green. The drift did still surface, but only in the + * other repo, only after a rebuild, on a different trigger. This puts detection + * in the repo where the edit happens. + * + * It checks the shape both ways: every link the builders reach lands on a + * pinned route, carrying a pinned view with exactly its pinned params; and + * every pinned route and view is reached by some builder. So the fixture can + * neither describe a link nobody emits nor miss one that is emitted. + * + * What it cannot see is the app. Whether those routes and view keys exist there + * is the app copy's job, and the two copies are still compared by hand. + */ + +const PINNED = fixture.links; + +/** The params each pinned view is allowed to carry, by view key. */ +const PINNED_VIEWS: Record = PINNED.views; + +/** One call per address the builders can reach. */ +const BUILT: ReadonlyArray = [ + ['flow', links.flow(FLOW)], + ['step', links.step({ ...FLOW, flow: 'web', step: 'destination.ga4' })], + [ + 'step (contract spelling)', + links.step({ ...FLOW, step: 'contract.checkout' }), + ], + ['release', links.release(FLOW)], + ['thread', links.thread({ ...FLOW, anchorType: 'release' })], + [ + 'deployment', + links.deployment({ + baseUrl: BASE, + projectId: 'proj_1', + deploymentId: 'dep_1', + }), + ], +]; + +function builtUrls(): URL[] { + return BUILT.map(([name, url]) => { + if (url === undefined) throw new Error(`Builder built no link: ${name}`); + return new URL(url); + }); +} + +/** + * A pinned template against a built path, segment by segment. A `{param}` + * matches any one non-empty segment: the template names its params as the + * BUILDER names them, so matching is by shape and position, never by name. + */ +function matchesTemplate(template: string, pathname: string): boolean { + const want = template.split('/').filter(Boolean); + const got = pathname.split('/').filter(Boolean); + return ( + want.length === got.length && + want.every((segment, index) => { + const other = got[index] ?? ''; + return segment.startsWith('{') ? other !== '' : segment === other; + }) + ); +} + +describe('the shared surface fixture', () => { + it('describes every link the builders build', () => { + for (const url of builtUrls()) { + const template = Object.values(PINNED.routes).find((candidate) => + matchesTemplate(candidate, url.pathname), + ); + expect(template).toBeDefined(); + + const view = url.searchParams.get(PINNED.viewParamKey); + const params = [...url.searchParams.keys()].filter( + (key) => key !== PINNED.viewParamKey, + ); + if (view === null) { + expect(params).toEqual([]); + continue; + } + const declared = PINNED_VIEWS[view]; + expect(declared).toBeDefined(); + expect(params.sort()).toEqual([...(declared ?? [])].sort()); + } + }); + + it('describes nothing the builders never build', () => { + const urls = builtUrls(); + for (const template of Object.values(PINNED.routes)) { + expect(urls.some((url) => matchesTemplate(template, url.pathname))).toBe( + true, + ); + } + const reached = urls + .map((url) => url.searchParams.get(PINNED.viewParamKey)) + .filter((view): view is string => view !== null); + expect([...new Set(reached)].sort()).toEqual( + Object.keys(PINNED_VIEWS).sort(), + ); + }); +}); diff --git a/packages/mcps/mcp/src/__tests__/mcp-surface-parity.test.ts b/packages/mcps/mcp/src/__tests__/mcp-surface-parity.test.ts index e6699115e..e77c833ae 100644 --- a/packages/mcps/mcp/src/__tests__/mcp-surface-parity.test.ts +++ b/packages/mcps/mcp/src/__tests__/mcp-surface-parity.test.ts @@ -10,12 +10,66 @@ import { HINT_ENDED, HINT_NO_WINDOW, } from '../tools/observe-session.js'; +import { + createHubManageToolSpec, + HUB_NOT_FOUND_HINT, + HUB_HINT_RELEASE_GET, + HUB_HINT_ROWS_ARE_DEPLOYMENTS, + HUB_HINT_STEP_HISTORY, + HUB_HINT_MASKED_ONLY, + HUB_HINT_TRACE_STEP, + HUB_HINT_WRITE_RATIONALE, + HUB_HINT_SCAN_CAPPED, + HUB_HINT_NO_MATCH, + HUB_HINT_OPEN_RELEASE, + HUB_HINT_RATIONALE_VISIBLE, + HUB_HINT_CONFIRM_INDEX, + HUB_HINT_THREADS_PAGE_CAPPED, + HUB_HINT_NOTHING_DISCUSSED, + HUB_HINT_NO_THREAD_ON_ANCHOR, + HUB_HINT_THREADS_INDEX, + HUB_HINT_MESSAGES_TRUNCATED, + HUB_HINT_REPLY_OR_OPEN, + HUB_HINT_RESOLVE_IN_APP, + HUB_HINT_MESSAGE_VISIBLE, + HUB_HINT_STAYS_RESOLVED, + HUB_HINT_READ_BACK, + HUB_HINT_THREAD_OPEN, + HUB_HINT_KEEP_ONE_THREAD, + HUB_HINT_KNOWLEDGE_PAGE_CAPPED, + HUB_HINT_NOTHING_WRITTEN, + HUB_HINT_KNOWLEDGE_INDEX, + HUB_HINT_ENTRY_NAMES_FLOW, + HUB_HINT_KNOWLEDGE_READ_ONLY, + HUB_HINT_READ_FRAME, +} from '../tools/hub-manage.js'; +import { + createFrameManageToolSpec, + FRAME_NOT_FOUND_HINT, + FRAME_HINT_OPEN_PAGE_OR_GET, + FRAME_HINT_NAMES_ARE_DOCUMENTATION, + FRAME_HINT_NONE_YET, + FRAME_HINT_MARK_SPACE, + FRAME_HINT_READ_KNOWLEDGE, + FRAME_HINT_NONE_ON_PAGE, + FRAME_HINT_EXTENDS_BASE, +} from '../tools/frame-manage.js'; +import { featureDenialHint } from '../tools/feature-gate.js'; import { stubClient } from './support/stub-client.js'; +import { hintsOf } from './support/tool-result.js'; import fixture from './fixtures/mcp-surface-parity.json'; import type { ToolClient, ObserveSessionResult, JourneysResult, + FlowReleaseWire, + ReleaseDetailWire, + VersionAnnotationWire, + StepHistoryWire, + HubThreadWire, + KnowledgeThreadWire, + FrameLeanWire, + FrameWire, } from '../tool-client.js'; /** @@ -30,6 +84,11 @@ import type { * implementations agree, and the identical fixture committed on the app side * is a second copy of one contract, not a second opinion about it. * + * NOTHING VERIFIES THE TWO COPIES ARE IDENTICAL. Each repository's test reads + * only its own copy and no script or CI step compares them, so editing one + * copy alone leaves both suites green while the two fixtures describe + * different surfaces. Keeping them in step is a manual diff at edit time. + * * WHAT IT DOES DO, and why it belongs at the source. It fails an unreviewed * change to the published tool surface here, before publish, instead of * downstream in a consumer that already pinned a version. It catches a renamed, @@ -48,7 +107,7 @@ const observeSessionSpec = () => createObserveSessionToolSpec(stubClient()); const declarativeToolNames = () => TOOL_DEFINITIONS.map((d) => d.name).sort(); -const HINTS: Record = { +const OBSERVE_HINTS: Record = { SIMULATE_FIRST: HINT_SIMULATE_FIRST, PREVIEW_STREAMS: HINT_PREVIEW_STREAMS, READ: HINT_READ, @@ -58,13 +117,62 @@ const HINTS: Record = { NO_WINDOW: HINT_NO_WINDOW, }; -/** Hints the handler actually emitted, mapped back to their fixture key names. */ -function emittedHintKeys(result: unknown): string[] { - const structured = (result as { structuredContent: Record }) - .structuredContent; - const next = (structured._hints as { next?: string[] } | undefined)?.next; - return (next ?? []).map((hint) => { - const entry = Object.entries(HINTS).find(([, value]) => value === hint); +const HUB_HINTS: Record = { + RELEASE_GET: HUB_HINT_RELEASE_GET, + ROWS_ARE_DEPLOYMENTS: HUB_HINT_ROWS_ARE_DEPLOYMENTS, + STEP_HISTORY: HUB_HINT_STEP_HISTORY, + MASKED_ONLY: HUB_HINT_MASKED_ONLY, + TRACE_STEP: HUB_HINT_TRACE_STEP, + WRITE_RATIONALE: HUB_HINT_WRITE_RATIONALE, + SCAN_CAPPED: HUB_HINT_SCAN_CAPPED, + NO_MATCH: HUB_HINT_NO_MATCH, + OPEN_RELEASE: HUB_HINT_OPEN_RELEASE, + RATIONALE_VISIBLE: HUB_HINT_RATIONALE_VISIBLE, + CONFIRM_INDEX: HUB_HINT_CONFIRM_INDEX, + THREADS_PAGE_CAPPED: HUB_HINT_THREADS_PAGE_CAPPED, + NOTHING_DISCUSSED: HUB_HINT_NOTHING_DISCUSSED, + NO_THREAD_ON_ANCHOR: HUB_HINT_NO_THREAD_ON_ANCHOR, + THREADS_INDEX: HUB_HINT_THREADS_INDEX, + MESSAGES_TRUNCATED: HUB_HINT_MESSAGES_TRUNCATED, + REPLY_OR_OPEN: HUB_HINT_REPLY_OR_OPEN, + RESOLVE_IN_APP: HUB_HINT_RESOLVE_IN_APP, + MESSAGE_VISIBLE: HUB_HINT_MESSAGE_VISIBLE, + STAYS_RESOLVED: HUB_HINT_STAYS_RESOLVED, + READ_BACK: HUB_HINT_READ_BACK, + THREAD_OPEN: HUB_HINT_THREAD_OPEN, + KEEP_ONE_THREAD: HUB_HINT_KEEP_ONE_THREAD, + KNOWLEDGE_PAGE_CAPPED: HUB_HINT_KNOWLEDGE_PAGE_CAPPED, + NOTHING_WRITTEN: HUB_HINT_NOTHING_WRITTEN, + KNOWLEDGE_INDEX: HUB_HINT_KNOWLEDGE_INDEX, + ENTRY_NAMES_FLOW: HUB_HINT_ENTRY_NAMES_FLOW, + KNOWLEDGE_READ_ONLY: HUB_HINT_KNOWLEDGE_READ_ONLY, + READ_FRAME: HUB_HINT_READ_FRAME, +}; + +const FRAME_HINTS: Record = { + OPEN_PAGE_OR_GET: FRAME_HINT_OPEN_PAGE_OR_GET, + NAMES_ARE_DOCUMENTATION: FRAME_HINT_NAMES_ARE_DOCUMENTATION, + NONE_YET: FRAME_HINT_NONE_YET, + MARK_SPACE: FRAME_HINT_MARK_SPACE, + READ_KNOWLEDGE: FRAME_HINT_READ_KNOWLEDGE, + NONE_ON_PAGE: FRAME_HINT_NONE_ON_PAGE, + EXTENDS_BASE: FRAME_HINT_EXTENDS_BASE, +}; + +/** + * Hints the handler actually emitted, mapped back to their fixture key names. + * + * The wording-to-key direction is what makes an ordering assertion readable: a + * failure names the hint that moved instead of printing two walls of prose. It + * also refuses an unknown hint outright, so a hint added to a path without + * being declared in the fixture fails here rather than passing unnoticed. + */ +function emittedHintKeys( + hints: Record, + result: unknown, +): string[] { + return hintsOf(result).map((hint) => { + const entry = Object.entries(hints).find(([, value]) => value === hint); if (!entry) throw new Error(`Hint is not in the fixture: ${hint}`); return entry[0]; }); @@ -119,7 +227,7 @@ async function hintKeysFor( const spec = createObserveSessionToolSpec( stubClient({ getDefaultProject: () => 'proj_1', ...overrides }), ); - return emittedHintKeys(await spec.handler(input)); + return emittedHintKeys(OBSERVE_HINTS, await spec.handler(input)); } describe('MCP surface parity', () => { @@ -127,11 +235,21 @@ describe('MCP surface parity', () => { // `diagnostics` is not in TOOL_DEFINITIONS at all: it is composed at // handler-build time from a client and a package version, so the roster is // pinned in two halves: the declarative registry below, and the full - // 17-name record asserted in create-tool-handlers.test.ts. + // 19-name record asserted in create-tool-handlers.test.ts. expect(fixture.toolNames.filter((name) => name !== 'diagnostics')).toEqual( declarativeToolNames(), ); expect(fixture.toolNames).toContain('diagnostics'); + // The roster is compared against a SORTED registry above, so the fixture + // has to be sorted too or the comparison would depend on the order someone + // happened to paste names in. Held separately from the count so a failure + // says which of the two went wrong. + expect(fixture.toolNames).toEqual([...fixture.toolNames].sort()); + // The absolute count. Everything above still passes when a tool is added to + // the registry AND the fixture in one edit, which is exactly the change + // that should be deliberate: growing the published surface has to move this + // number by hand. + expect(fixture.toolNames).toHaveLength(19); }); it('registers observe_session with the pinned name, title, and annotations', () => { @@ -162,16 +280,22 @@ describe('MCP surface parity', () => { ); }); - it('emits each pinned ordering on the path the fixture names it for', async () => { + it('emits exactly the pinned orderings, one per path the fixture names', async () => { // Closes the loop the checks above leave open: they prove the fixture is // internally consistent and that the hint WORDING matches, but nothing so // far ties an ordering to the path it claims to describe. Without this, the // fixture could name any order for `statusEmptyFeed` and stay green. Each // case below drives the real handler down one path. - const { hintOrder } = fixture.observeSession; - - expect( - await hintKeysFor( + // + // Collected into ONE object and compared ONCE, never path by path. A + // per-path expectation proves its own value and nothing else, so deleting a + // case would leave every other test here green: the completeness check + // below compares the fixture's declared hints against its own referenced + // hints, and never against the paths this test actually drives. Comparing + // the whole map in a single assertion proves the orderings and the coverage + // together, so a dropped path fails right here. + const emitted: Record = { + start: await hintKeysFor( { getFlow: async () => ({ settings: [{ id: 'cfg_0', name: 'web', platform: 'web' }], @@ -180,51 +304,38 @@ describe('MCP surface parity', () => { }, { action: 'start', flowId: 'flow_1' }, ), - ).toEqual(hintOrder.start); - - expect( - await hintKeysFor( + statusWithRecords: await hintKeysFor( { getObserveSession: async () => session() }, { action: 'status', flowId: 'flow_1', sessionId: 'ses_1' }, ), - ).toEqual(hintOrder.statusWithRecords); - - expect( - await hintKeysFor( + statusEmptyFeed: await hintKeysFor( { getObserveSession: async () => session({ recordsReceived: 0 }) }, { action: 'status', flowId: 'flow_1', sessionId: 'ses_1' }, ), - ).toEqual(hintOrder.statusEmptyFeed); - - expect( - await hintKeysFor( + statusNoWindow: await hintKeysFor( { getObserveSession: async () => session(), listJourneys: async () => journeys(null), }, { action: 'status', flowId: 'flow_1' }, ), - ).toEqual(hintOrder.statusNoWindow); - - expect( - await hintKeysFor( + stopEnded: await hintKeysFor( { endObserveSession: async () => undefined, listJourneys: async () => journeys('ses_live'), }, { action: 'stop', flowId: 'flow_1' }, ), - ).toEqual(hintOrder.stopEnded); - - expect( - await hintKeysFor( + stopNoWindow: await hintKeysFor( { endObserveSession: async () => undefined, listJourneys: async () => journeys(null), }, { action: 'stop', flowId: 'flow_1' }, ), - ).toEqual(hintOrder.stopNoWindow); + }; + + expect(emitted).toEqual(fixture.observeSession.hintOrder); }); it('declares exactly the hints its emission orderings reference', () => { @@ -237,6 +348,14 @@ describe('MCP surface parity', () => { }); it('pins the hint constants to the fixture wording', () => { + // Uniqueness first, because it is what makes the reverse lookup in + // `emittedHintKeys` a function at all. That lookup scans VALUES, so two + // constants sharing one sentence would both resolve to whichever key comes + // first, and an ordering assertion would keep passing while the handler + // emitted the other one. Asserted on the map the lookup actually reads. + expect(new Set(Object.values(OBSERVE_HINTS)).size).toBe( + Object.keys(OBSERVE_HINTS).length, + ); // The one place the imported constants are compared to text. Reword a hint // in the tool and this fails, naming the hint. Everywhere else the // constants stand in for hint IDENTITY only, never for wording. @@ -260,3 +379,504 @@ describe('MCP surface parity', () => { expect(observeTools).toEqual(['observe_journeys', 'observe_session']); }); }); + +/** + * The two gated tools. Both are pinned the same way observe_session is, with + * two additions the gate gives them: `denialHint`, the sentence a door's + * FEATURE_NOT_AVAILABLE turns into, and `notFoundHint`, the one a NOT_FOUND + * turns into. Those two travel with the surface because an agent reads them, + * so a reword of either is a surface change like any other. + */ + +const FRAME_ID = 'frm_V1StGXR8Z5jdHi6BmyT7K'; + +function releaseRow(): FlowReleaseWire { + return { + id: 'dv_1', + deploymentId: 'dep_1', + deploymentSlug: 'shop-web', + deploymentType: 'web', + versionNumber: 3, + flowVersionId: 'ver_a', + flowVersionNumber: 14, + status: 'active', + source: 'app', + errorCode: null, + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + createdByLabel: 'Ayla', + rationale: null, + }; +} + +function annotation(): VersionAnnotationWire { + return { + versionId: 'ver_a', + humanText: 'swapped the measurement id', + generatedSummary: null, + author: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + }; +} + +/** A release with a non-empty diff and no rationale: the plainest detail read. */ +function releaseDetail( + overrides: Partial = {}, +): ReleaseDetailWire { + return { + versionId: 'ver_a', + versionNumber: 14, + contentHash: 'h14', + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + rationale: null, + diff: { + prevVersionId: 'ver_p', + prevVersionNumber: 13, + text: '- id: G-1\n+ id: G-2', + contentIdentical: false, + }, + ...overrides, + }; +} + +function stepHistory( + overrides: Partial = {}, +): StepHistoryWire { + return { + step: 'destination.ga4', + flow: null, + entries: [ + { + versionId: 'ver_a', + versionNumber: 14, + createdAt: '2026-09-01T00:00:00.000Z', + flow: 'web', + change: 'changed', + humanText: null, + generatedSummary: null, + }, + ], + scanned: 20, + truncated: false, + entriesTruncated: false, + ...overrides, + }; +} + +function hubThread(overrides: Partial = {}): HubThreadWire { + return { + id: 'thr_1', + anchorType: 'release', + anchorKey: 'ver_a', + anchorLabel: 'v14', + status: 'open', + resolvedByVersionId: null, + resolvedByVersionNumber: null, + resolvedAt: null, + resolvedBy: null, + createdBy: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + messageCount: 4, + ...overrides, + }; +} + +function knowledgeThread( + overrides: Partial = {}, +): KnowledgeThreadWire { + return { + kind: 'thread', + id: 'kt_1', + anchorType: 'tag', + anchorKey: `${FRAME_ID}:m1`, + anchorLabel: 'Add to cart', + frameId: FRAME_ID, + frameName: 'Cart', + flowId: 'flow_1', + subjectKey: 'product.add', + spatial: null, + validity: { tier: 'none' }, + freshness: 'unknown', + author: { kind: 'user', id: 'user_1', label: 'Ayla' }, + source: 'tag_mode', + updatedAt: '2026-09-01T00:00:00.000Z', + status: 'open', + createdAt: '2026-09-01T00:00:00.000Z', + messageCount: 4, + ...overrides, + }; +} + +function leanFrame(overrides: Partial = {}): FrameLeanWire { + return { + id: FRAME_ID, + projectId: 'proj_1', + name: 'Cart', + parentId: null, + placements: [{ id: 'pl_1', rect: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 } }], + size: { width: 800, height: 400 }, + extends: null, + source: { + kind: 'page', + key: 'https://shop.example/cart', + url: 'https://shop.example/cart?utm=1', + }, + origin: 'drawn', + flowId: 'flow_1', + screenshot: null, + version: 3, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-02T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + deletedAt: null, + ...overrides, + }; +} + +function fullFrame(overrides: Partial = {}): FrameWire { + return { ...leanFrame(), marks: { entities: [] }, ...overrides }; +} + +async function hubHintKeysFor( + overrides: Partial, + input: Record, +): Promise { + const spec = createHubManageToolSpec( + stubClient({ getDefaultProject: () => 'proj_1', ...overrides }), + ); + return emittedHintKeys(HUB_HINTS, await spec.handler(input)); +} + +async function frameHintKeysFor( + overrides: Partial, + input: Record, +): Promise { + const spec = createFrameManageToolSpec( + stubClient({ getDefaultProject: () => 'proj_1', ...overrides }), + ); + return emittedHintKeys(FRAME_HINTS, await spec.handler(input)); +} + +describe('MCP surface parity: hub_manage', () => { + const hubSpec = () => createHubManageToolSpec(stubClient()); + + it('registers hub_manage with the pinned name, title, and annotations', () => { + const spec = hubSpec(); + expect(spec.name).toBe(fixture.hubManage.name); + expect(spec.title).toBe(fixture.hubManage.title); + expect(spec.annotations).toEqual(fixture.hubManage.annotations); + }); + + it('pins the description in both places the package writes it', () => { + const declared = TOOL_DEFINITIONS.find((d) => d.name === 'hub_manage'); + expect(declared).toBeDefined(); + expect(hubSpec().description).toBe(fixture.hubManage.description); + expect(declared?.description).toBe(fixture.hubManage.description); + }); + + it('registers hub_manage with the pinned input schema', () => { + expect(z.toJSONSchema(z.object(hubSpec().inputSchema))).toEqual( + fixture.hubManage.inputSchema, + ); + }); + + it('pins the two hints a refusal turns into', () => { + // Neither is emitted on a success path, so neither is reachable through + // hintOrder. They are the sentences an agent reads when the tool refuses, + // which makes them surface, and this is where their wording is pinned. + expect(featureDenialHint('hub')).toBe(fixture.hubManage.denialHint); + expect(HUB_NOT_FOUND_HINT).toBe(fixture.hubManage.notFoundHint); + }); + + it('emits exactly the pinned orderings, one per path the fixture names', async () => { + // Collected into ONE object and compared ONCE. See the note on the + // observe_session block: a per-path expectation cannot notice its own + // deletion, and nothing else here would. This matters most for the copy of + // this fixture that the app pins, since a second test exercising three of + // these eighteen paths would otherwise pass while claiming the same surface. + const emitted: Record = { + releases: await hubHintKeysFor( + { + listReleases: async () => ({ + releases: [releaseRow()], + total: 1, + limit: 20, + offset: 0, + }), + }, + { action: 'releases', flowId: 'flow_1' }, + ), + releaseGetMaskedOnly: await hubHintKeysFor( + { + getRelease: async () => + releaseDetail({ + diff: { + prevVersionId: 'ver_p', + prevVersionNumber: 13, + text: '', + contentIdentical: false, + }, + }), + }, + { action: 'release_get', flowId: 'flow_1', versionId: 'ver_a' }, + ), + releaseGetWithRationale: await hubHintKeysFor( + { getRelease: async () => releaseDetail({ rationale: annotation() }) }, + { action: 'release_get', flowId: 'flow_1', versionId: 'ver_a' }, + ), + releaseGetNoRationale: await hubHintKeysFor( + { getRelease: async () => releaseDetail() }, + { action: 'release_get', flowId: 'flow_1', versionId: 'ver_a' }, + ), + stepHistoryCapped: await hubHintKeysFor( + { + listStepHistory: async () => stepHistory({ entriesTruncated: true }), + }, + { action: 'step_history', flowId: 'flow_1', step: 'destination.ga4' }, + ), + stepHistoryEmpty: await hubHintKeysFor( + { listStepHistory: async () => stepHistory({ entries: [] }) }, + { action: 'step_history', flowId: 'flow_1', step: 'destination.ga4' }, + ), + stepHistoryMatched: await hubHintKeysFor( + { listStepHistory: async () => stepHistory() }, + { action: 'step_history', flowId: 'flow_1', step: 'destination.ga4' }, + ), + rationaleSet: await hubHintKeysFor( + { + // The write resolves the release through the detail read first, so + // both have to answer for this path to run at all. + getRelease: async () => releaseDetail(), + setReleaseRationale: async () => annotation(), + }, + { + action: 'rationale_set', + flowId: 'flow_1', + versionId: 'ver_a', + text: 'swapped the measurement id', + }, + ), + threadsIndexEmpty: await hubHintKeysFor( + { listThreads: async () => ({ threads: [], hasMoreThreads: false }) }, + { action: 'threads', flowId: 'flow_1' }, + ), + threadsAnchorEmpty: await hubHintKeysFor( + { + getRelease: async () => releaseDetail(), + listThreads: async () => ({ threads: [], hasMoreThreads: false }), + }, + { action: 'threads', flowId: 'flow_1', versionId: 'ver_a' }, + ), + threadsIndexCapped: await hubHintKeysFor( + { + listThreads: async () => ({ + threads: [hubThread()], + hasMoreThreads: true, + }), + }, + { action: 'threads', flowId: 'flow_1' }, + ), + threadsAnchorTruncated: await hubHintKeysFor( + { + getRelease: async () => releaseDetail(), + listThreads: async () => ({ + threads: [ + hubThread({ + hasMoreMessages: true, + messages: [ + { + id: 'msg_1', + author: 'user_1', + text: 'looks right', + createdAt: '2026-09-01T00:00:00.000Z', + }, + ], + }), + ], + hasMoreThreads: false, + }), + }, + { action: 'threads', flowId: 'flow_1', versionId: 'ver_a' }, + ), + noteAddReplyOpen: await hubHintKeysFor( + { addThreadMessage: async () => hubThread({ status: 'open' }) }, + { + action: 'note_add', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'agreed', + }, + ), + noteAddReplyResolved: await hubHintKeysFor( + { addThreadMessage: async () => hubThread({ status: 'resolved' }) }, + { + action: 'note_add', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'agreed', + }, + ), + noteAddOpen: await hubHintKeysFor( + { + createThread: async () => + hubThread({ + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'destination.ga4', + }), + }, + { + action: 'note_add', + flowId: 'flow_1', + anchorType: 'step', + anchorKey: 'destination.ga4', + text: 'why this changed', + }, + ), + knowledgeEmpty: await hubHintKeysFor( + { listKnowledge: async () => ({ entries: [], hasMoreEntries: false }) }, + { action: 'knowledge' }, + ), + knowledgeIndexCapped: await hubHintKeysFor( + { + listKnowledge: async () => ({ + entries: [knowledgeThread()], + hasMoreEntries: true, + }), + }, + { action: 'knowledge' }, + ), + knowledgeMarkTruncated: await hubHintKeysFor( + { + listKnowledge: async () => ({ + entries: [ + knowledgeThread({ + hasMoreMessages: true, + messages: [ + { + id: 'km_1', + author: 'user_1', + authorLabel: 'Ayla', + text: 'fires on the CTA', + createdAt: '2026-09-01T00:00:00.000Z', + clientMessageId: null, + }, + ], + }), + ], + hasMoreEntries: false, + }), + }, + { action: 'knowledge', frameId: FRAME_ID, markId: 'm1' }, + ), + }; + + expect(emitted).toEqual(fixture.hubManage.hintOrder); + }); + + it('declares exactly the hints its emission orderings reference', () => { + const { hints, hintOrder } = fixture.hubManage; + const declared = Object.keys(hints).sort(); + const referenced = Array.from( + new Set(Object.values(hintOrder).flat()), + ).sort(); + expect(referenced).toEqual(declared); + }); + + it('pins the hint constants to the fixture wording', () => { + // Uniqueness first: `emittedHintKeys` maps a hint back to a key by scanning + // VALUES, so two constants sharing one sentence would both resolve to + // whichever key comes first and every ordering above would keep passing + // while the handler emitted the other one. + expect(new Set(Object.values(HUB_HINTS)).size).toBe( + Object.keys(HUB_HINTS).length, + ); + expect(HUB_HINTS).toEqual(fixture.hubManage.hints); + }); +}); + +describe('MCP surface parity: frame_manage', () => { + const frameSpec = () => createFrameManageToolSpec(stubClient()); + + it('registers frame_manage with the pinned name, title, and annotations', () => { + const spec = frameSpec(); + expect(spec.name).toBe(fixture.frameManage.name); + expect(spec.title).toBe(fixture.frameManage.title); + expect(spec.annotations).toEqual(fixture.frameManage.annotations); + }); + + it('pins the description in both places the package writes it', () => { + const declared = TOOL_DEFINITIONS.find((d) => d.name === 'frame_manage'); + expect(declared).toBeDefined(); + expect(frameSpec().description).toBe(fixture.frameManage.description); + expect(declared?.description).toBe(fixture.frameManage.description); + }); + + it('registers frame_manage with the pinned input schema', () => { + expect(z.toJSONSchema(z.object(frameSpec().inputSchema))).toEqual( + fixture.frameManage.inputSchema, + ); + }); + + it('pins the two hints a refusal turns into', () => { + expect(featureDenialHint('frames')).toBe(fixture.frameManage.denialHint); + expect(FRAME_NOT_FOUND_HINT).toBe(fixture.frameManage.notFoundHint); + }); + + it('emits exactly the pinned orderings, one per path the fixture names', async () => { + // One object, one comparison, for the reason stated on the hub_manage + // block: coverage of the pinned paths is part of what this asserts. + const emitted: Record = { + list: await frameHintKeysFor( + { listFrames: async () => ({ frames: [leanFrame()] }) }, + { action: 'list' }, + ), + listEmpty: await frameHintKeysFor( + { listFrames: async () => ({ frames: [] }) }, + { action: 'list' }, + ), + page: await frameHintKeysFor( + { listPageFrames: async () => ({ frames: [fullFrame()] }) }, + { action: 'page', pageKey: 'https://shop.example/cart' }, + ), + pageEmpty: await frameHintKeysFor( + { listPageFrames: async () => ({ frames: [] }) }, + { action: 'page', pageKey: 'https://shop.example/cart' }, + ), + getVariation: await frameHintKeysFor( + { getFrame: async () => fullFrame({ extends: 'frm_base' }) }, + { action: 'get', frameId: FRAME_ID }, + ), + getBase: await frameHintKeysFor( + { getFrame: async () => fullFrame() }, + { action: 'get', frameId: FRAME_ID }, + ), + }; + + expect(emitted).toEqual(fixture.frameManage.hintOrder); + }); + + it('declares exactly the hints its emission orderings reference', () => { + const { hints, hintOrder } = fixture.frameManage; + const declared = Object.keys(hints).sort(); + const referenced = Array.from( + new Set(Object.values(hintOrder).flat()), + ).sort(); + expect(referenced).toEqual(declared); + }); + + it('pins the hint constants to the fixture wording', () => { + // Uniqueness first: `emittedHintKeys` maps a hint back to a key by scanning + // VALUES, so two constants sharing one sentence would both resolve to + // whichever key comes first and every ordering above would keep passing + // while the handler emitted the other one. + expect(new Set(Object.values(FRAME_HINTS)).size).toBe( + Object.keys(FRAME_HINTS).length, + ); + expect(FRAME_HINTS).toEqual(fixture.frameManage.hints); + }); +}); diff --git a/packages/mcps/mcp/src/__tests__/public-api.test.ts b/packages/mcps/mcp/src/__tests__/public-api.test.ts index de5696da2..123936ee8 100644 --- a/packages/mcps/mcp/src/__tests__/public-api.test.ts +++ b/packages/mcps/mcp/src/__tests__/public-api.test.ts @@ -30,11 +30,22 @@ jest.mock('@walkeros/cli', () => ({ getDeploymentBySlug: jest.fn(), deleteDeployment: jest.fn(), listJourneys: jest.fn(), - requestDeviceCode: jest.fn(), - pollForToken: jest.fn(), + listReleases: jest.fn(), + getRelease: jest.fn(), + listStepHistory: jest.fn(), + setReleaseRationale: jest.fn(), + listThreads: jest.fn(), + createThread: jest.fn(), + addThreadMessage: jest.fn(), + listKnowledge: jest.fn(), + listFrames: jest.fn(), + listPageFrames: jest.fn(), + getFrame: jest.fn(), + startDeviceAuthorization: jest.fn(), + completeDeviceLogin: jest.fn(), whoami: jest.fn(), - resolveToken: jest.fn(), - deleteConfig: jest.fn(), + credentialSource: jest.fn(), + logout: jest.fn(), feedback: jest.fn(), getFeedbackPreference: jest.fn(), setFeedbackPreference: jest.fn(), @@ -92,7 +103,7 @@ describe('public API surface', () => { it('exports TOOL_DEFINITIONS array', () => { expect(Array.isArray(api.TOOL_DEFINITIONS)).toBe(true); - expect(api.TOOL_DEFINITIONS.length).toBe(16); + expect(api.TOOL_DEFINITIONS.length).toBe(18); }); it('exports the observe_session description and every next-hint', () => { diff --git a/packages/mcps/mcp/src/__tests__/server-telemetry.test.ts b/packages/mcps/mcp/src/__tests__/server-telemetry.test.ts index 1794a554a..138e890c8 100644 --- a/packages/mcps/mcp/src/__tests__/server-telemetry.test.ts +++ b/packages/mcps/mcp/src/__tests__/server-telemetry.test.ts @@ -83,11 +83,23 @@ function stubClient(): ToolClient { getDeploymentBySlug: notImpl, deleteDeployment: notImpl, listJourneys: notImpl, + listReleases: notImpl, + getRelease: notImpl, + listStepHistory: notImpl, + setReleaseRationale: notImpl, + listThreads: notImpl, + createThread: notImpl, + addThreadMessage: notImpl, + listKnowledge: notImpl, + listFrames: notImpl, + listPageFrames: notImpl, + getFrame: notImpl, requestDeviceCode: notImpl, pollForToken: notImpl, whoami: notImpl, - resolveToken: () => null, - deleteConfig: () => false, + credentialSource: () => null, + logout: async () => ({ deleted: false }), + appBaseUrl: () => 'https://app.walkeros.io', checkHealth: async () => ({ reachable: true }), submitFeedback: notImpl, getFeedbackPreference: () => undefined, diff --git a/packages/mcps/mcp/src/__tests__/server.test.ts b/packages/mcps/mcp/src/__tests__/server.test.ts index 527f6cb07..941937297 100644 --- a/packages/mcps/mcp/src/__tests__/server.test.ts +++ b/packages/mcps/mcp/src/__tests__/server.test.ts @@ -67,11 +67,23 @@ function stubClient(): ToolClient { getDeploymentBySlug: notImpl, deleteDeployment: notImpl, listJourneys: notImpl, + listReleases: notImpl, + getRelease: notImpl, + listStepHistory: notImpl, + setReleaseRationale: notImpl, + listThreads: notImpl, + createThread: notImpl, + addThreadMessage: notImpl, + listKnowledge: notImpl, + listFrames: notImpl, + listPageFrames: notImpl, + getFrame: notImpl, requestDeviceCode: notImpl, pollForToken: notImpl, whoami: notImpl, - resolveToken: () => null, - deleteConfig: () => false, + credentialSource: () => null, + logout: async () => ({ deleted: false }), + appBaseUrl: () => 'https://app.walkeros.io', checkHealth: async () => ({ reachable: true }), submitFeedback: notImpl, getFeedbackPreference: () => undefined, @@ -89,7 +101,7 @@ describe('createWalkerOSMcpServer', () => { expect(server.server).toBeDefined(); }); - it('registers all 17 tools', () => { + it('registers all 19 tools', () => { const server = createWalkerOSMcpServer({ client: stubClient(), version: '0.0.0', @@ -111,6 +123,8 @@ describe('createWalkerOSMcpServer', () => { 'flow_push', 'flow_simulate', 'flow_validate', + 'frame_manage', + 'hub_manage', 'observe_journeys', 'observe_session', 'package_get', diff --git a/packages/mcps/mcp/src/__tests__/support/stub-client.ts b/packages/mcps/mcp/src/__tests__/support/stub-client.ts index 3906ad236..514c741e8 100644 --- a/packages/mcps/mcp/src/__tests__/support/stub-client.ts +++ b/packages/mcps/mcp/src/__tests__/support/stub-client.ts @@ -32,11 +32,23 @@ export function stubClient(overrides: Partial = {}): ToolClient { getDeploymentBySlug: notImpl, deleteDeployment: notImpl, listJourneys: notImpl, + listReleases: notImpl, + getRelease: notImpl, + listStepHistory: notImpl, + setReleaseRationale: notImpl, + listThreads: notImpl, + createThread: notImpl, + addThreadMessage: notImpl, + listKnowledge: notImpl, + listFrames: notImpl, + listPageFrames: notImpl, + getFrame: notImpl, requestDeviceCode: notImpl, pollForToken: notImpl, whoami: notImpl, - resolveToken: () => null, - deleteConfig: () => false, + credentialSource: () => null, + logout: async () => ({ deleted: false }), + appBaseUrl: () => 'https://app.walkeros.io', checkHealth: async () => ({ reachable: true }), submitFeedback: notImpl, getFeedbackPreference: () => undefined, diff --git a/packages/mcps/mcp/src/__tests__/support/tool-result.ts b/packages/mcps/mcp/src/__tests__/support/tool-result.ts new file mode 100644 index 000000000..272ad2f18 --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/support/tool-result.ts @@ -0,0 +1,41 @@ +/** + * Narrowing helpers for tool handler results. A handler is typed + * `(input: unknown) => Promise` by design, so a test that wants one + * field out of a result narrows it through these guards rather than casting: + * a shape that is not what the test claims fails here, naming what was there. + */ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** The structuredContent of a tool result, or a failure naming what was there instead. */ +export function structured(result: unknown): Record { + if (!isRecord(result) || !isRecord(result.structuredContent)) { + throw new Error(`Not a tool result: ${JSON.stringify(result)}`); + } + return result.structuredContent; +} + +/** One nested object field, narrowed. */ +export function record(value: unknown): Record { + if (!isRecord(value)) + throw new Error(`Not an object: ${JSON.stringify(value)}`); + return value; +} + +/** An array of objects, narrowed element by element. */ +export function rows(value: unknown): Record[] { + if (!Array.isArray(value)) + throw new Error(`Not an array: ${JSON.stringify(value)}`); + return value.map(record); +} + +/** The `next` hints a handler emitted, in order. */ +export function hintsOf(result: unknown): string[] { + const next = record(structured(result)._hints).next; + if (!Array.isArray(next) || !next.every((hint) => typeof hint === 'string')) { + throw new Error('No next hints on this result'); + } + return next; +} diff --git a/packages/mcps/mcp/src/__tests__/tool-definitions.test.ts b/packages/mcps/mcp/src/__tests__/tool-definitions.test.ts index debc16d57..b08f97f05 100644 --- a/packages/mcps/mcp/src/__tests__/tool-definitions.test.ts +++ b/packages/mcps/mcp/src/__tests__/tool-definitions.test.ts @@ -1,7 +1,7 @@ import { TOOL_DEFINITIONS } from '../tool-definitions.js'; describe('TOOL_DEFINITIONS', () => { - it('lists all 16 tools by name', () => { + it('lists all 18 tools by name', () => { const names = TOOL_DEFINITIONS.map((d) => d.name).sort(); expect(names).toEqual( [ @@ -15,6 +15,8 @@ describe('TOOL_DEFINITIONS', () => { 'flow_push', 'flow_simulate', 'flow_validate', + 'frame_manage', + 'hub_manage', 'observe_journeys', 'observe_session', 'package_get', diff --git a/packages/mcps/mcp/src/__tests__/tools/auth.test.ts b/packages/mcps/mcp/src/__tests__/tools/auth.test.ts index 84ae787e1..69fcc5481 100644 --- a/packages/mcps/mcp/src/__tests__/tools/auth.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/auth.test.ts @@ -70,7 +70,7 @@ describe('auth tool', () => { userId: 'usr_1', }); const client = stubClient({ - resolveToken: () => ({ token: 'tok_123', source: 'config' }), + credentialSource: () => 'config', whoami, }); registerAuthTool(server as never, client); @@ -85,10 +85,10 @@ describe('auth tool', () => { expect(result.structuredContent.email).toBe('user@example.com'); }); - it('returns not authenticated when no token', async () => { + it('returns not authenticated when no credential is available', async () => { const whoami = jest.fn(); const client = stubClient({ - resolveToken: () => null, + credentialSource: () => null, whoami, }); registerAuthTool(server as never, client); @@ -144,12 +144,7 @@ describe('auth tool', () => { it('only calls pollForToken when deviceCode is provided (retry)', async () => { const requestDeviceCode = jest.fn(); - const pollForToken = jest.fn().mockResolvedValue({ - success: true, - status: 'authenticated', - email: 'user@example.com', - configPath: '/home/.config/walkeros/config.json', - }); + const pollForToken = jest.fn().mockResolvedValue({ status: 'ok' }); const client = stubClient({ requestDeviceCode, pollForToken }); registerAuthTool(server as never, client); @@ -168,10 +163,7 @@ describe('auth tool', () => { it('returns pending with deviceCode on retry timeout', async () => { const requestDeviceCode = jest.fn(); - const pollForToken = jest.fn().mockResolvedValue({ - success: false, - status: 'pending', - }); + const pollForToken = jest.fn().mockResolvedValue({ status: 'pending' }); const client = stubClient({ requestDeviceCode, pollForToken }); registerAuthTool(server as never, client); @@ -183,6 +175,7 @@ describe('auth tool', () => { structuredContent: { authenticated: boolean; status: string; + message: string; deviceCode: string; }; }; @@ -194,13 +187,59 @@ describe('auth tool', () => { expect(result.structuredContent.authenticated).toBe(false); expect(result.structuredContent.status).toBe('pending'); expect(result.structuredContent.deviceCode).toBe('dev_timeout'); + expect(result.structuredContent.message).toContain('shortly'); + }); + + it('asks for a longer wait on slow_down, still returning the device code', async () => { + // The control for the pending case above: both are "keep waiting", so + // the differing advice must come from the status and not from the branch + // that renders it. + const pollForToken = jest.fn().mockResolvedValue({ status: 'slow_down' }); + const client = stubClient({ pollForToken }); + registerAuthTool(server as never, client); + + const tool = server.getTool('auth')!; + const result = (await tool.handler({ + action: 'login', + deviceCode: 'dev_slow', + })) as { + structuredContent: { + authenticated: boolean; + status: string; + message: string; + deviceCode: string; + }; + }; + + expect(result.structuredContent.authenticated).toBe(false); + expect(result.structuredContent.status).toBe('pending'); + expect(result.structuredContent.deviceCode).toBe('dev_slow'); + expect(result.structuredContent.message).toContain('longer'); + }); + + it.each([ + ['denied', 'denied'], + ['expired', 'expired'], + ])('reports %s as a distinct error', async (status, expected) => { + const pollForToken = jest.fn().mockResolvedValue({ status }); + const client = stubClient({ pollForToken }); + registerAuthTool(server as never, client); + + const tool = server.getTool('auth')!; + const result = (await tool.handler({ + action: 'login', + deviceCode: 'dev_terminal', + })) as { isError: boolean; content: Array<{ text: string }> }; + + expect(result.isError).toBe(true); + const parsed: unknown = JSON.parse(result.content[0]!.text); + expect(parsed).toHaveProperty('error', expect.stringContaining(expected)); }); it('returns error when poll fails with error status', async () => { const pollForToken = jest.fn().mockResolvedValue({ - success: false, status: 'error', - error: 'access_denied', + error: 'invalid_client', }); const client = stubClient({ pollForToken }); registerAuthTool(server as never, client); @@ -208,12 +247,12 @@ describe('auth tool', () => { const tool = server.getTool('auth')!; const result = (await tool.handler({ action: 'login', - deviceCode: 'dev_denied', + deviceCode: 'dev_broken', })) as { isError: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); - const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toBe('access_denied'); + const parsed: unknown = JSON.parse(result.content[0]!.text); + expect(parsed).toHaveProperty('error', 'invalid_client'); }); }); @@ -228,10 +267,10 @@ describe('auth tool', () => { } }); - it('calls deleteConfig and returns success', async () => { + it('revokes the session through logout and returns success', async () => { delete process.env.WALKEROS_TOKEN; - const deleteConfig = jest.fn().mockReturnValue(true); - const client = stubClient({ deleteConfig }); + const logout = jest.fn().mockResolvedValue({ deleted: true }); + const client = stubClient({ logout }); registerAuthTool(server as never, client); const tool = server.getTool('auth')!; @@ -239,15 +278,15 @@ describe('auth tool', () => { structuredContent: { loggedOut: boolean; message: string }; }; - expect(deleteConfig).toHaveBeenCalled(); + expect(logout).toHaveBeenCalled(); expect(result.structuredContent.loggedOut).toBe(true); expect(result.structuredContent.message).toContain('Logged out'); }); it('returns success even when no config existed', async () => { delete process.env.WALKEROS_TOKEN; - const deleteConfig = jest.fn().mockReturnValue(false); - const client = stubClient({ deleteConfig }); + const logout = jest.fn().mockResolvedValue({ deleted: false }); + const client = stubClient({ logout }); registerAuthTool(server as never, client); const tool = server.getTool('auth')!; @@ -261,8 +300,8 @@ describe('auth tool', () => { it('clears WALKEROS_TOKEN env var and mentions it in the message', async () => { process.env.WALKEROS_TOKEN = 'tok_env_abc'; - const deleteConfig = jest.fn().mockReturnValue(true); - const client = stubClient({ deleteConfig }); + const logout = jest.fn().mockResolvedValue({ deleted: true }); + const client = stubClient({ logout }); registerAuthTool(server as never, client); const tool = server.getTool('auth')!; @@ -270,7 +309,7 @@ describe('auth tool', () => { structuredContent: { loggedOut: boolean; message: string }; }; - expect(deleteConfig).toHaveBeenCalled(); + expect(logout).toHaveBeenCalled(); expect(process.env.WALKEROS_TOKEN).toBeUndefined(); expect(result.structuredContent.loggedOut).toBe(true); expect(result.structuredContent.message).toContain('Config removed'); @@ -279,10 +318,10 @@ describe('auth tool', () => { it('subsequent status call reports unauthenticated after logout with env token', async () => { process.env.WALKEROS_TOKEN = 'tok_env_xyz'; - const deleteConfig = jest.fn().mockReturnValue(true); + const logout = jest.fn().mockResolvedValue({ deleted: true }); const client = stubClient({ - deleteConfig, - resolveToken: () => null, + logout, + credentialSource: () => null, }); registerAuthTool(server as never, client); @@ -298,8 +337,8 @@ describe('auth tool', () => { it('clears env token even when no config existed', async () => { process.env.WALKEROS_TOKEN = 'tok_env_only'; - const deleteConfig = jest.fn().mockReturnValue(false); - const client = stubClient({ deleteConfig }); + const logout = jest.fn().mockResolvedValue({ deleted: false }); + const client = stubClient({ logout }); registerAuthTool(server as never, client); const tool = server.getTool('auth')!; diff --git a/packages/mcps/mcp/src/__tests__/tools/deploy-manage.test.ts b/packages/mcps/mcp/src/__tests__/tools/deploy-manage.test.ts index 65511393e..9eadca00c 100644 --- a/packages/mcps/mcp/src/__tests__/tools/deploy-manage.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/deploy-manage.test.ts @@ -133,6 +133,7 @@ describe('deploy_manage tool', () => { expect(deploy).toHaveBeenCalledWith({ flowId: 'flow_1', + projectId: undefined, wait: true, flowName: 'my-flow', }); @@ -148,6 +149,7 @@ describe('deploy_manage tool', () => { expect(deploy).toHaveBeenCalledWith({ flowId: 'flow_1', + projectId: undefined, wait: true, flowName: undefined, }); @@ -198,6 +200,7 @@ describe('deploy_manage tool', () => { expect(deploy).toHaveBeenCalledWith({ flowId: 'flow_1', + projectId: undefined, wait: false, flowName: undefined, }); @@ -501,6 +504,172 @@ describe('deploy_manage tool', () => { }); }); + // A link belongs in the structured result, not only in prose: an agent reads + // it as data and hands it on without retyping it out of a sentence. + describe('links into the app', () => { + it('links the deployment page a deploy just started', async () => { + const deploy = jest + .fn() + .mockResolvedValue({ deploymentId: 'dep_1', slug: 'abc123456789' }); + registerDeployTool(server as never, stubClient({ deploy })); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'deploy', + projectId: 'proj_1', + flowId: 'flow_abc', + })) as { structuredContent: { appUrl?: string } }; + + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/deployments/dep_1', + ); + }); + + it('deploys in the project it links into', async () => { + // The link resolves `explicit ?? default`. A deploy that resolved the + // default instead would run in one project and link into another, and + // the project-scoped deployment page would answer that link with a 404. + const deploy = jest.fn().mockResolvedValue({ deploymentId: 'dep_1' }); + registerDeployTool( + server as never, + stubClient({ deploy, getDefaultProject: () => 'proj_default' }), + ); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'deploy', + projectId: 'proj_explicit', + flowId: 'flow_abc', + })) as { structuredContent: { appUrl?: string } }; + + expect(deploy).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj_explicit' }), + ); + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_explicit/deployments/dep_1', + ); + }); + + it('links the deployment page a get read', async () => { + const listDeployments = jest + .fn() + .mockResolvedValue({ deployments: [DEPLOYMENT_ONE] }); + const getDeploymentBySlug = jest + .fn() + .mockResolvedValue({ id: 'dep_1', slug: DEPLOYMENT_ONE.slug }); + registerDeployTool( + server as never, + stubClient({ listDeployments, getDeploymentBySlug }), + ); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'get', + projectId: 'proj_1', + flowId: 'flow_abc', + })) as { structuredContent: { appUrl?: string } }; + + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/deployments/dep_1', + ); + }); + + // The API's own `url` is where the deployment SERVES. Overwriting it with + // the app page would swap a live endpoint for a UI link with nothing to + // say the meaning had changed, so the link travels under its own key. + it('leaves the serving url a get returned untouched', async () => { + const listDeployments = jest + .fn() + .mockResolvedValue({ deployments: [DEPLOYMENT_ONE] }); + const getDeploymentBySlug = jest.fn().mockResolvedValue({ + id: 'dep_1', + slug: DEPLOYMENT_ONE.slug, + status: 'active', + url: 'https://collect.example.com', + }); + registerDeployTool( + server as never, + stubClient({ listDeployments, getDeploymentBySlug }), + ); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'get', + projectId: 'proj_1', + flowId: 'flow_abc', + })) as { structuredContent: { url?: string; appUrl?: string } }; + + expect(result.structuredContent.url).toBe('https://collect.example.com'); + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/deployments/dep_1', + ); + }); + + it('leaves the serving url a finished deploy returned untouched', async () => { + // The hosted shape: `wait: true` merges the terminal status onto the + // start body, so the deploy response carries `url` as well. + const deploy = jest.fn().mockResolvedValue({ + deploymentId: 'dep_1', + slug: 'abc123456789', + status: 'active', + url: 'https://collect.example.com', + }); + registerDeployTool(server as never, stubClient({ deploy })); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'deploy', + projectId: 'proj_1', + flowId: 'flow_abc', + })) as { structuredContent: { url?: string; appUrl?: string } }; + + expect(result.structuredContent.url).toBe('https://collect.example.com'); + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/deployments/dep_1', + ); + }); + + it('links nothing for a response carrying only a slug', async () => { + // The detail route resolves a slug, but the page's live-status stream + // matches on the id alone, so a slug link opens a page whose stream + // fails mid-deploy. + const listDeployments = jest + .fn() + .mockResolvedValue({ deployments: [DEPLOYMENT_ONE] }); + const getDeploymentBySlug = jest + .fn() + .mockResolvedValue({ slug: DEPLOYMENT_ONE.slug, status: 'active' }); + registerDeployTool( + server as never, + stubClient({ listDeployments, getDeploymentBySlug }), + ); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'get', + projectId: 'proj_1', + flowId: 'flow_abc', + })) as { structuredContent: Record }; + + expect(result.structuredContent).not.toHaveProperty('appUrl'); + }); + + it('links nothing when no project can be named', async () => { + // The stub door has no default project, and none was passed. A link + // built on a guessed project would point into someone else's work. + const deploy = jest.fn().mockResolvedValue({ deploymentId: 'dep_1' }); + registerDeployTool(server as never, stubClient({ deploy })); + + const tool = server.getTool('deploy_manage')!; + const result = (await tool.handler({ + action: 'deploy', + flowId: 'flow_abc', + })) as { structuredContent: Record }; + + expect(result.structuredContent).not.toHaveProperty('appUrl'); + }); + }); + describe('error handling', () => { it('catches errors and returns mcpError with auth hint', async () => { const listDeployments = jest diff --git a/packages/mcps/mcp/src/__tests__/tools/feature-gate.test.ts b/packages/mcps/mcp/src/__tests__/tools/feature-gate.test.ts new file mode 100644 index 000000000..f1330d7fa --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/tools/feature-gate.test.ts @@ -0,0 +1,75 @@ +import { + errorHint, + featureDenialHint, + isFeatureDenial, +} from '../../tools/feature-gate.js'; +import { AUTH_HINT } from '../../types.js'; + +class CodedError extends Error { + constructor( + message: string, + readonly code?: string, + ) { + super(message); + } +} + +describe('feature gate', () => { + it('recognises a denial by its code, not its wording', () => { + expect( + isFeatureDenial(new CodedError('anything', 'FEATURE_NOT_AVAILABLE')), + ).toBe(true); + expect( + isFeatureDenial( + new CodedError('hub is not available on your current plan'), + ), + ).toBe(false); + expect(isFeatureDenial('FEATURE_NOT_AVAILABLE')).toBe(false); + }); + + it.each(['hub', 'frames'] as const)( + 'names the feature %s in the hint', + (feature) => { + expect(featureDenialHint(feature)).toContain(`"${feature}"`); + }, + ); + + // The second message is the one that proves the ordering: `isAuthError` + // answers to the word "forbidden" anywhere in a message, so a denial worded + // that way reaches the auth hint the moment the two checks swap places. + it.each([ + ['reads as a plan limit', 'frames is not available on your current plan'], + ['also reads as an auth failure', 'Forbidden'], + ])( + 'picks the denial hint over the auth hint when the message %s', + (_label, message) => { + const error = new CodedError(message, 'FEATURE_NOT_AVAILABLE'); + expect(errorHint(error, 'frames', 'find ids')).toBe( + featureDenialHint('frames'), + ); + }, + ); + + it('keeps the auth hint for an auth failure', () => { + expect( + errorHint( + new CodedError('Unauthorized', 'UNAUTHORIZED'), + 'hub', + 'find ids', + ), + ).toBe(AUTH_HINT); + }); + + it('adds the discovery hint on NOT_FOUND and nothing otherwise', () => { + expect( + errorHint( + new CodedError('Release not found', 'NOT_FOUND'), + 'hub', + 'find ids', + ), + ).toBe('find ids'); + expect( + errorHint(new CodedError('boom'), 'hub', 'find ids'), + ).toBeUndefined(); + }); +}); diff --git a/packages/mcps/mcp/src/__tests__/tools/flow-load.test.ts b/packages/mcps/mcp/src/__tests__/tools/flow-load.test.ts index 03305bac8..a04a9a6e3 100644 --- a/packages/mcps/mcp/src/__tests__/tools/flow-load.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/flow-load.test.ts @@ -280,7 +280,7 @@ describe('flow_load tool', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(parsed.error).not.toContain('Flow not found'); expect(getFlow).not.toHaveBeenCalled(); }); diff --git a/packages/mcps/mcp/src/__tests__/tools/flow-manage-preview.test.ts b/packages/mcps/mcp/src/__tests__/tools/flow-manage-preview.test.ts index eca552d99..32da83e9d 100644 --- a/packages/mcps/mcp/src/__tests__/tools/flow-manage-preview.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/flow-manage-preview.test.ts @@ -105,7 +105,7 @@ describe('flow_manage tool — preview actions', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(parsed.error).not.toContain('Project not found'); expect(listPreviews).not.toHaveBeenCalled(); }); @@ -394,7 +394,7 @@ describe('flow_manage tool — preview actions', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(parsed.error).not.toContain('Project not found'); expect(createPreview).not.toHaveBeenCalled(); }); @@ -737,7 +737,10 @@ describe('flow_manage tool — preview actions', () => { it('preview_get strips token and projectId from a raw API response', async () => { const getPreview = jest.fn().mockResolvedValue(rawApiPreview); - registerFlowManageTool(server as never, stubClient({ getPreview })); + registerFlowManageTool( + server as never, + stubClient({ getPreview, getDefaultProject: () => 'proj_default' }), + ); const tool = server.getTool('flow_manage')!; const result = (await tool.handler( { action: 'preview_get', flowId: 'cfg_1', previewId: 'prv_raw' }, diff --git a/packages/mcps/mcp/src/__tests__/tools/flow-manage-user-data.test.ts b/packages/mcps/mcp/src/__tests__/tools/flow-manage-user-data.test.ts index d98495639..fdb38eeb0 100644 --- a/packages/mcps/mcp/src/__tests__/tools/flow-manage-user-data.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/flow-manage-user-data.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from '@jest/globals'; import { createFlowManageToolSpec } from '../../tools/flow-manage'; +import { stubClient } from '../support/stub-client.js'; import type { ToolClient } from '../../tool-client'; function makeClient(overrides: Partial = {}): ToolClient { - const base = { + const base: Partial = { listFlows: async () => ({ flows: [ { @@ -62,7 +63,7 @@ function makeClient(overrides: Partial = {}): ToolClient { deleteFlow: async () => ({ ok: true }), getDefaultProject: () => 'p1', }; - return { ...base, ...overrides } as unknown as ToolClient; + return stubClient({ ...base, ...overrides }); } describe('flow_manage outputs user_data-delimited strings', () => { @@ -149,7 +150,7 @@ describe('flow_manage outputs user_data-delimited strings', () => { updatedAt: new Date().toISOString(), }; }, - } as unknown as Partial); + }); const spec = createFlowManageToolSpec(client); const got = (await spec.handler({ action: 'get', diff --git a/packages/mcps/mcp/src/__tests__/tools/flow-manage.test.ts b/packages/mcps/mcp/src/__tests__/tools/flow-manage.test.ts index b42ac0e29..1d55ee49c 100644 --- a/packages/mcps/mcp/src/__tests__/tools/flow-manage.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/flow-manage.test.ts @@ -191,11 +191,55 @@ describe('flow_manage tool', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(parsed.error).not.toContain('Flow not found'); expect(getFlow).not.toHaveBeenCalled(); }); + /** + * Six of the eleven actions once skipped the project resolver and fell + * straight through to a service error with no remedy in it, which is how a + * real session lost a config. The roster is the point of this case: an + * action added without the guard fails here instead of in someone's chat. + * + * `list` is deliberately absent. Without a project it lists across every + * project the caller belongs to, which is an answer rather than a failure. + */ + it.each([ + ['get', { flowId: 'flow_1' }], + ['update', { flowId: 'flow_1' }], + ['delete', { flowId: 'flow_1' }], + ['duplicate', { flowId: 'flow_1' }], + ['create', { name: 'New flow' }], + ['preview_list', { flowId: 'flow_1' }], + ['preview_get', { flowId: 'flow_1', previewId: 'prev_1' }], + ['preview_create', { flowId: 'flow_1', flowName: 'My Flow' }], + ['preview_delete', { flowId: 'flow_1', previewId: 'prev_1' }], + ['preview_regrant', { flowId: 'flow_1', previewId: 'prev_1' }], + ])( + 'action %s refuses without a project and names the remedy', + async (action, params) => { + registerFlowManageTool( + server as never, + stubClient({ getDefaultProject: () => null }), + ); + + const tool = server.getTool('flow_manage')!; + const result = (await tool.handler({ + action, + ...(params as Record), + })) as { isError: boolean; content: Array<{ text: string }> }; + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + // Both remedies. Either one alone still leaves a caller stuck: the + // per-call argument is the one that always works, and the selection is + // the one that saves repeating it. + expect(parsed.error).toContain('Pass projectId on this call'); + expect(parsed.error).toContain('set_default'); + }, + ); + it('uses the default project when no projectId provided', async () => { const flow = { id: 'flow_1', name: 'My Flow', content: {} }; const getFlow = jest.fn().mockResolvedValue(flow); @@ -290,7 +334,7 @@ describe('flow_manage tool', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text); - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(parsed.error).not.toContain('Project not found'); expect(createFlow).not.toHaveBeenCalled(); }); @@ -357,7 +401,10 @@ describe('flow_manage tool', () => { it('defaults patch to true (passes mergePatch: true)', async () => { const updated = { id: 'flow_1', name: 'Updated' }; const updateFlow = jest.fn().mockResolvedValue(updated); - registerFlowManageTool(server as never, stubClient({ updateFlow })); + registerFlowManageTool( + server as never, + stubClient({ updateFlow, getDefaultProject: () => 'proj_default' }), + ); const tool = server.getTool('flow_manage')!; const result = (await tool.handler({ @@ -368,7 +415,7 @@ describe('flow_manage tool', () => { expect(updateFlow).toHaveBeenCalledWith({ flowId: 'flow_1', - projectId: undefined, + projectId: 'proj_default', name: 'Updated', content: undefined, mergePatch: true, @@ -395,7 +442,10 @@ describe('flow_manage tool', () => { it('calls deleteFlow', async () => { const deleteFlow = jest.fn().mockResolvedValue({ success: true }); - registerFlowManageTool(server as never, stubClient({ deleteFlow })); + registerFlowManageTool( + server as never, + stubClient({ deleteFlow, getDefaultProject: () => 'proj_default' }), + ); const tool = server.getTool('flow_manage')!; const result = (await tool.handler({ @@ -405,7 +455,7 @@ describe('flow_manage tool', () => { expect(deleteFlow).toHaveBeenCalledWith({ flowId: 'flow_1', - projectId: undefined, + projectId: 'proj_default', }); expect(result.structuredContent.success).toBe(true); }); @@ -447,6 +497,60 @@ describe('flow_manage tool', () => { }); }); + // A link belongs in the structured result, not only in prose: an agent reads + // it as data and hands it on without retyping it out of a sentence. + describe('links into the app', () => { + it('links the flow page a get read', async () => { + const getFlow = jest + .fn() + .mockResolvedValue({ id: 'flow_1', name: 'My Flow', content: {} }); + registerFlowManageTool(server as never, stubClient({ getFlow })); + + const tool = server.getTool('flow_manage')!; + const result = (await tool.handler({ + action: 'get', + flowId: 'flow_1', + projectId: 'proj_1', + })) as { structuredContent: { appUrl?: string } }; + + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_1', + ); + }); + + it('links the flow page a create just made', async () => { + const createFlow = jest + .fn() + .mockResolvedValue({ id: 'flow_new', name: 'New Flow' }); + registerFlowManageTool(server as never, stubClient({ createFlow })); + + const tool = server.getTool('flow_manage')!; + const result = (await tool.handler({ + action: 'create', + name: 'New Flow', + projectId: 'proj_1', + })) as { structuredContent: { appUrl?: string } }; + + expect(result.structuredContent.appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_new', + ); + }); + + it('links nothing when the response carried no flow id', async () => { + const getFlow = jest.fn().mockResolvedValue({ name: 'My Flow' }); + registerFlowManageTool(server as never, stubClient({ getFlow })); + + const tool = server.getTool('flow_manage')!; + const result = (await tool.handler({ + action: 'get', + flowId: 'flow_1', + projectId: 'proj_1', + })) as { structuredContent: Record }; + + expect(result.structuredContent).not.toHaveProperty('appUrl'); + }); + }); + describe('error handling', () => { it('catches errors and returns mcpError with auth hint', async () => { const listAllFlows = jest diff --git a/packages/mcps/mcp/src/__tests__/tools/frame-manage.test.ts b/packages/mcps/mcp/src/__tests__/tools/frame-manage.test.ts new file mode 100644 index 000000000..6e715e42e --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/tools/frame-manage.test.ts @@ -0,0 +1,647 @@ +import { stubClient } from '../support/stub-client.js'; +import { + createFrameManageToolSpec, + FRAME_HINT_OPEN_PAGE_OR_GET, + FRAME_HINT_NAMES_ARE_DOCUMENTATION, + FRAME_HINT_NONE_YET, + FRAME_HINT_MARK_SPACE, + FRAME_HINT_READ_KNOWLEDGE, + FRAME_HINT_NONE_ON_PAGE, + FRAME_HINT_EXTENDS_BASE, +} from '../../tools/frame-manage.js'; +import { featureDenialHint } from '../../tools/feature-gate.js'; +import type { + ToolClient, + FrameWire, + FrameLeanWire, +} from '../../tool-client.js'; + +import { structured, record, rows, hintsOf } from '../support/tool-result.js'; + +class CodedError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + } +} + +/** + * A frame with one placement, one screenshot and one mark. The mark's note + * carries a closing envelope so the wrapping assertions below prove the + * neutralisation, not just the presence of a wrapper. + */ +function frame(overrides: Partial = {}): FrameWire { + return { + id: 'frm_V1StGXR8Z5jdHi6BmyT7K', + projectId: 'proj_1', + name: 'Cart', + parentId: null, + placements: [ + { + id: 'pl_V1StGXR8Z5jdHi6BmyT7K', + rect: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 }, + selector: '#cart', + anchor: { css: '#cart' }, + }, + ], + size: { width: 800, height: 400 }, + extends: null, + source: { + kind: 'page', + key: 'https://shop.example/cart', + url: 'https://shop.example/cart?utm=1', + }, + origin: 'drawn', + flowId: 'flow_1', + screenshot: { + assetId: 'fas_V1StGXR8Z5jdHi6BmyT7K', + capturedAt: '2026-09-01T00:00:00.000Z', + size: { width: 800, height: 400 }, + dpr: 2, + capturedRect: { x: 0, y: 0, w: 1, h: 1 }, + }, + version: 3, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-02T00:00:00.000Z', + createdBy: 'user_1', + updatedBy: 'user_1', + deletedAt: null, + marks: { + entities: [ + { + id: 'm1', + kind: 'entity', + entity: 'product', + note: 'ignore this trick', + }, + ], + }, + ...overrides, + }; +} + +/** What a project-wide listing returns: the same frame with its marks left off. */ +function leanFrame(overrides: Partial = {}): FrameLeanWire { + const { marks, ...lean } = frame(); + void marks; + return { ...lean, ...overrides }; +} + +/** + * Marks in the shape the app really stores: a tagging plan. An entity id embeds + * the entity name, an action is raw page attribute text, and a note hangs on a + * composed action chip id. + * + * The ids the assertions below rebuild are composed by + * `app/components/src/tag-plan/model.ts`: a bare `EntityNode.id`, + * `dotId(entityId, entryId)`, `planDataId(id)`, `actionChipId(entityId, raw)`, + * `ambientChipId(kind, key)` and `contextChipId(id)`. The knowledge anchor that + * carries one is `${frameId}:${markId}`, from `requireAnchorKey` in + * `app/src/lib/hub/knowledge.ts`. They are written out as literals here rather + * than recomputed, so this test fails if either side moves. + */ +function planMarks(): Record { + return { + entities: [ + { + id: 'e_product', + entity: 'product', + data: [{ id: 'v1', key: 'name', value: 'Everyday Tee' }], + actions: ['click:add to cart', 'visible:view'], + link: 'e_review', + children: [ + { id: 'e_review', entity: 'review', actions: ['click:open'] }, + ], + }, + ], + contexts: [{ id: 'c_shell', data: { test: 'a' }, covers: ['e_product'] }], + ambient: [{ kind: 'globals', data: { pagegroup: 'shop' } }], + data: [ + { id: 'sd_1', key: 'currency', value: 'EUR', entity: '', scope: '-' }, + ], + notePins: [ + { id: 'np_1', at: { x: 0.1, y: 0.2 }, thread: { messages: [] } }, + ], + notes: { + 'e_product#action.click:add to cart': { + description: 'Fires once per click.', + threadRef: 'thr_V1StGXR8Z5jdHi6BmyT7K', + }, + }, + }; +} + +/** The marks of the single frame a `get` returned. */ +function marksOf(result: unknown): Record { + return record(record(structured(result).frame).marks); +} + +const withProject = (overrides: Partial = {}) => + createFrameManageToolSpec( + stubClient({ getDefaultProject: () => 'proj_1', ...overrides }), + ); + +describe('frame_manage', () => { + it('is a read-only action tool', () => { + const spec = withProject(); + expect(spec.name).toBe('frame_manage'); + expect(spec.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }); + }); + + it('lists frames without marks, wrapping the name and reducing placements', async () => { + const result = await withProject({ + listFrames: async () => ({ frames: [leanFrame()] }), + }).handler({ action: 'list' }); + const row = rows(structured(result).frames)[0]; + expect(row).toMatchObject({ + id: 'frm_V1StGXR8Z5jdHi6BmyT7K', + name: 'Cart', + parentId: null, + extends: null, + source: { + kind: 'page', + key: 'https://shop.example/cart', + url: 'https://shop.example/cart?utm=1', + }, + screenshot: { + assetId: 'fas_V1StGXR8Z5jdHi6BmyT7K', + capturedAt: '2026-09-01T00:00:00.000Z', + dpr: 2, + }, + version: 3, + }); + // The exact projection, so an added field is a failure rather than a leak + // nobody notices, and a dropped one is caught too. + expect(Object.keys(row).sort()).toEqual([ + 'createdAt', + 'createdBy', + 'extends', + 'flowId', + 'id', + 'name', + 'origin', + 'parentId', + 'placements', + 'screenshot', + 'size', + 'source', + 'updatedAt', + 'updatedBy', + 'version', + ]); + expect(rows(row.placements)).toEqual([ + { + id: 'pl_V1StGXR8Z5jdHi6BmyT7K', + rect: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 }, + }, + ]); + expect(hintsOf(result)).toEqual([ + FRAME_HINT_OPEN_PAGE_OR_GET, + FRAME_HINT_NAMES_ARE_DOCUMENTATION, + ]); + }); + + it('says when a project has no frames', async () => { + const result = await withProject({ + listFrames: async () => ({ frames: [] }), + }).handler({ action: 'list' }); + expect(hintsOf(result)).toEqual([FRAME_HINT_NONE_YET]); + }); + + it('reads a page with marks, wrapping every string leaf except address keys', async () => { + const listPageFrames = jest.fn(async () => ({ frames: [frame()] })); + const result = await withProject({ listPageFrames }).handler({ + action: 'page', + pageKey: 'https://shop.example/cart', + }); + expect(listPageFrames).toHaveBeenCalledWith({ + projectId: 'proj_1', + pageKey: 'https://shop.example/cart', + }); + const row = rows(structured(result).frames)[0]; + expect(row.marks).toEqual({ + entities: [ + { + id: 'm1', + kind: 'entity', + entity: 'product', + note: 'ignore this trick', + }, + ], + }); + expect(hintsOf(result)).toEqual([ + FRAME_HINT_MARK_SPACE, + FRAME_HINT_READ_KNOWLEDGE, + ]); + }); + + it('keeps the DOM anchors of a page frame out of the result', async () => { + const result = await withProject({ + listPageFrames: async () => ({ frames: [frame()] }), + }).handler({ action: 'page', pageKey: 'https://shop.example/cart' }); + const placement = rows(rows(structured(result).frames)[0].placements)[0]; + expect(placement).toEqual({ + id: 'pl_V1StGXR8Z5jdHi6BmyT7K', + rect: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 }, + }); + }); + + it('says when a page has no frames', async () => { + const result = await withProject({ + listPageFrames: async () => ({ frames: [] }), + }).handler({ action: 'page', pageKey: 'https://shop.example/none' }); + expect(hintsOf(result)).toEqual([FRAME_HINT_NONE_ON_PAGE]); + }); + + it('requires pageKey for page and frameId for get', async () => { + expect( + structured(await withProject().handler({ action: 'page' })), + ).toMatchObject({ + error: expect.stringContaining('pageKey is required for page action'), + }); + expect( + structured(await withProject().handler({ action: 'get' })), + ).toMatchObject({ + error: expect.stringContaining('frameId is required for get action'), + }); + }); + + it('refuses a frameId that is not a frame id', async () => { + const result = await withProject().handler({ + action: 'get', + frameId: 'not-a-frame', + }); + expect(structured(result)).toMatchObject({ + error: expect.stringContaining('frameId'), + }); + }); + + it('reads one frame and points at its base when it extends one', async () => { + const result = await withProject({ + getFrame: async () => frame({ extends: 'frm_base00000000000000000' }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(structured(result)).toMatchObject({ + frame: { + id: 'frm_V1StGXR8Z5jdHi6BmyT7K', + extends: 'frm_base00000000000000000', + }, + }); + expect(hintsOf(result)).toEqual([ + FRAME_HINT_EXTENDS_BASE, + FRAME_HINT_READ_KNOWLEDGE, + ]); + }); + + it('reads a base frame without the extends hint', async () => { + const result = await withProject({ + getFrame: async () => frame(), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(hintsOf(result)).toEqual([FRAME_HINT_READ_KNOWLEDGE]); + }); + + it('wraps every source string except the ones an action takes back', async () => { + // `kind` is how a reader branches and half of an ambient mark id; `key` is + // the pageKey the "page" action takes. Nothing else here is an input. + const result = await withProject({ + getFrame: async () => + frame({ + source: { kind: 'figma', fileKey: 'fk_1', nodeId: '3:14' }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(record(structured(result).frame).source).toEqual({ + kind: 'figma', + fileKey: 'fk_1', + nodeId: '3:14', + }); + }); + + it('echoes the page key back literally, because the caller passed it in', async () => { + const result = await withProject({ + listPageFrames: async () => ({ frames: [] }), + }).handler({ action: 'page', pageKey: 'https://shop.example/cart' }); + expect(structured(result).pageKey).toBe('https://shop.example/cart'); + }); + + it('wraps mark values under key names that are structural elsewhere', async () => { + // Marks are a passthrough record, so a client may write any key. Only the + // names the mark ids are composed from stay literal; a name that is + // structural in a flow config carries free text here. + const result = await withProject({ + getFrame: async () => + frame({ + marks: { + ambient: [ + { + id: 'a1', + kind: 'globals', + package: 'free text', + platform: 'anything', + slug: 'a slug', + version: 'a version', + flowId: 'a flow', + projectId: 'a project', + previewId: 'a preview', + createdAt: 'whenever', + updatedAt: 'whenever', + deletedAt: 'whenever', + }, + ], + }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(rows(marksOf(result).ambient)[0]).toEqual({ + id: 'a1', + kind: 'globals', + package: 'free text', + platform: 'anything', + slug: 'a slug', + version: 'a version', + flowId: 'a flow', + projectId: 'a project', + previewId: 'a preview', + createdAt: 'whenever', + updatedAt: 'whenever', + deletedAt: 'whenever', + }); + }); + + it('wraps the keys of a prose-keyed record but not an address-keyed one', async () => { + const result = await withProject({ + getFrame: async () => + frame({ + marks: { + contexts: [ + { + id: 'c_shell', + data: { 'test group': 'b', empty: null }, + covers: [], + }, + ], + ambient: [{ kind: 'globals', data: { pagegroup: 'shop' } }], + }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const marks = marksOf(result); + // A context property name addresses nothing, so it becomes a wrapped pair. + expect(rows(marks.contexts)[0].data).toEqual([ + { + key: 'test group', + value: 'b', + }, + { key: 'empty', value: null }, + ]); + // An ambient property name is the second half of its mark id, so the record + // keeps its shape and the key stays literal. + const ambient = rows(marks.ambient)[0]; + expect(ambient.data).toEqual({ pagegroup: 'shop' }); + expect( + `ambient.${ambient.kind}.${Object.keys(record(ambient.data))[0]}`, + ).toBe('ambient.globals.pagegroup'); + }); + + it('wraps everything inside a mark anchor, including its DOM id', async () => { + // `anchor.ids.id` is `el.id` read off the host page, never an address a + // tool takes back, so it wraps like its testid and name siblings. + const result = await withProject({ + getFrame: async () => + frame({ + marks: { + entities: [ + { + id: 'e_product', + anchor: { + css: '#cart > .row', + ids: { id: 'cart-row', testid: 'cart', name: 'cartRow' }, + text: 'Add to cart', + }, + }, + ], + }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const entity = rows(marksOf(result).entities)[0]; + // The mark id on the SAME object stays literal: the exemption is scoped to + // the anchor subtree, not lost on any object that carries one. + expect(entity.id).toBe('e_product'); + expect(entity.anchor).toEqual({ + css: '#cart > .row', + ids: { + id: 'cart-row', + testid: 'cart', + name: 'cartRow', + }, + text: 'Add to cart', + }); + }); + + it('wraps a per-action anchor the same way as a mark anchor', async () => { + // Anchors also hang under `actionAnchors`, keyed by the raw action text. + const result = await withProject({ + getFrame: async () => + frame({ + marks: { + entities: [ + { + id: 'e_product', + actions: ['click:add'], + actionAnchors: { + 'click:add': { + css: '#add', + ids: { id: 'add-btn' }, + }, + }, + }, + ], + }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const entity = rows(marksOf(result).entities)[0]; + expect(record(record(entity.actionAnchors)['click:add'])).toEqual({ + css: '#add', + ids: { id: 'add-btn' }, + }); + // The action pairing is untouched by the anchor rule. + expect(rows(entity.actions)[0]).toEqual({ + id: 'e_product#action.click:add', + raw: 'click:add', + }); + }); + + it('resolves a context to the entity it covers from the read alone', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const marks = marksOf(result); + const context = rows(marks.contexts)[0]; + // The join is performed, not asserted around: a wrapped covers element + // matches no entity id and this find returns nothing. + const covered = context.covers; + const coveredIds = Array.isArray(covered) ? covered : []; + const entity = rows(marks.entities).find((node) => + coveredIds.includes(node.id), + ); + expect(entity?.id).toBe('e_product'); + expect(`context.${context.id}`).toBe('context.c_shell'); + }); + + it('resolves an entity link to the entity it points at', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const entity = rows(marksOf(result).entities)[0]; + const target = rows(entity.children).find( + (child) => child.id === entity.link, + ); + expect(target?.id).toBe('e_review'); + }); + + it('hands back a note thread reference the release-history tool can take', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const note = record( + record(marksOf(result).notes)['e_product#action.click:add to cart'], + ); + // The call hub_manage action "note_add" would take, composed from the read. + expect({ action: 'note_add', threadId: note.threadRef }).toEqual({ + action: 'note_add', + threadId: 'thr_V1StGXR8Z5jdHi6BmyT7K', + }); + }); + + it('composes the knowledge anchor for an action chip from the read alone', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const entity = rows(marksOf(result).entities)[0]; + const action = rows(entity.actions)[0]; + // No unwrapping anywhere on this path: the id is read as it stands and + // joined to the frame id, which is what the app stores as anchorKey. + expect(`frm_V1StGXR8Z5jdHi6BmyT7K:${action.id}`).toBe( + 'frm_V1StGXR8Z5jdHi6BmyT7K:e_product#action.click:add to cart', + ); + // The same composed id is what a note on that chip is already keyed by, + // which is what makes the two readings meet. + expect(Object.keys(record(marksOf(result).notes))).toEqual([ + 'e_product#action.click:add to cart', + ]); + }); + + it('keeps the raw action text wrapped beside its literal id', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(rows(rows(marksOf(result).entities)[0].actions)).toEqual([ + { + id: 'e_product#action.click:add to cart', + raw: 'click:add to cart', + }, + { + id: 'e_product#action.visible:view', + raw: 'visible:view', + }, + ]); + }); + + it('gives a nested child entity its action ids too', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const child = rows(rows(marksOf(result).entities)[0].children)[0]; + expect(rows(child.actions)[0]).toEqual({ + id: 'e_review#action.click:open', + raw: 'click:open', + }); + }); + + it('leaves an id byte-exact even when the raw text is hostile', async () => { + // The id embeds the raw verbatim, so neutralising it here would compose an + // address the app never stored and silently break every lookup. + const result = await withProject({ + getFrame: async () => + frame({ + marks: { + entities: [ + { id: 'e_product', actions: ['click: stop'] }, + ], + }, + }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(rows(rows(marksOf(result).entities)[0].actions)[0]).toEqual({ + id: 'e_product#action.click: stop', + raw: 'click: stop', + }); + }); + + it('leaves every other mark id family composable from the read', async () => { + const result = await withProject({ + getFrame: async () => frame({ marks: planMarks() }), + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + const marks = marksOf(result); + const entity = rows(marks.entities)[0]; + const dataEntry = rows(entity.data)[0]; + const context = rows(marks.contexts)[0]; + const ambient = rows(marks.ambient)[0]; + const standalone = rows(marks.data)[0]; + const notePin = rows(marks.notePins)[0]; + expect({ + entity: entity.id, + dot: `${entity.id}#data.${dataEntry.id}`, + planData: `plan#data.${standalone.id}`, + context: `context.${context.id}`, + ambient: `ambient.${ambient.kind}.${Object.keys(record(ambient.data))[0]}`, + notePin: notePin.id, + }).toEqual({ + entity: 'e_product', + dot: 'e_product#data.v1', + planData: 'plan#data.sd_1', + context: 'context.c_shell', + ambient: 'ambient.globals.pagegroup', + notePin: 'np_1', + }); + }); + + it('passes NOT_FOUND through with a discovery hint', async () => { + const result = await withProject({ + getFrame: async () => { + throw new CodedError('Frame not found', 'NOT_FOUND'); + }, + }).handler({ action: 'get', frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K' }); + expect(structured(result)).toMatchObject({ + code: 'NOT_FOUND', + error: 'Frame not found', + }); + expect(typeof structured(result).hint).toBe('string'); + }); + + it('passes a feature denial through with the frames hint', async () => { + const result = await withProject({ + listFrames: async () => { + throw new CodedError( + 'frames is not available on your current plan', + 'FEATURE_NOT_AVAILABLE', + ); + }, + }).handler({ action: 'list' }); + expect(structured(result)).toMatchObject({ + code: 'FEATURE_NOT_AVAILABLE', + hint: featureDenialHint('frames'), + }); + }); + + it('asks for a project when there is none', async () => { + const result = await createFrameManageToolSpec(stubClient()).handler({ + action: 'list', + }); + expect(structured(result)).toMatchObject({ + error: expect.stringContaining('No project selected'), + }); + }); +}); diff --git a/packages/mcps/mcp/src/__tests__/tools/hub-manage.test.ts b/packages/mcps/mcp/src/__tests__/tools/hub-manage.test.ts new file mode 100644 index 000000000..21eba5c03 --- /dev/null +++ b/packages/mcps/mcp/src/__tests__/tools/hub-manage.test.ts @@ -0,0 +1,833 @@ +import { stubClient } from '../support/stub-client.js'; +import { + createHubManageToolSpec, + HUB_HINT_RELEASE_GET, + HUB_HINT_ROWS_ARE_DEPLOYMENTS, + HUB_HINT_STEP_HISTORY, + HUB_HINT_MASKED_ONLY, + HUB_HINT_WRITE_RATIONALE, + HUB_HINT_TRACE_STEP, + HUB_HINT_SCAN_CAPPED, + HUB_HINT_NO_MATCH, + HUB_HINT_OPEN_RELEASE, + HUB_HINT_NOTHING_WRITTEN, + HUB_HINT_READ_FRAME, +} from '../../tools/hub-manage.js'; +import { featureDenialHint } from '../../tools/feature-gate.js'; +import type { + FlowReleaseWire, + ReleaseDetailWire, + StepHistoryWire, + HubThreadWire, + KnowledgeEntryWire, + ToolClient, +} from '../../tool-client.js'; +import { structured, record, rows, hintsOf } from '../support/tool-result.js'; + +class CodedError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + } +} + +function release(overrides: Partial = {}): FlowReleaseWire { + return { + id: 'dv_1', + deploymentId: 'dep_1', + deploymentSlug: 'shop-web', + deploymentType: 'web', + versionNumber: 3, + flowVersionId: 'ver_a', + flowVersionNumber: 14, + status: 'active', + source: 'app', + errorCode: null, + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + createdByLabel: 'Ayla', + rationale: null, + ...overrides, + }; +} + +function detail(overrides: Partial = {}): ReleaseDetailWire { + return { + versionId: 'ver_a', + versionNumber: 14, + contentHash: 'h14', + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + rationale: null, + diff: { + prevVersionId: 'ver_p', + prevVersionNumber: 13, + text: '- id: G-1\n+ id: G-2', + contentIdentical: false, + }, + ...overrides, + }; +} + +/** + * A scan that found one release. The entry carries rationale text on the wire, + * which is what makes the omission assertion below meaningful. + */ +function stepHistory( + overrides: Partial = {}, +): StepHistoryWire { + return { + step: 'destination.ga4', + flow: null, + entries: [ + { + versionId: 'ver_a', + versionNumber: 14, + createdAt: '2026-09-01T00:00:00.000Z', + flow: 'web', + change: 'changed', + humanText: 'swapped the measurement id', + generatedSummary: 'destination.ga4 changed', + }, + ], + scanned: 20, + truncated: false, + entriesTruncated: false, + ...overrides, + }; +} + +function thread(overrides: Partial = {}): HubThreadWire { + return { + id: 'thr_1', + anchorType: 'release', + anchorKey: 'ver_a', + anchorLabel: 'v14', + status: 'open', + resolvedByVersionId: null, + resolvedByVersionNumber: null, + resolvedAt: null, + resolvedBy: null, + createdBy: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + messageCount: 1, + ...overrides, + }; +} + +function description(): KnowledgeEntryWire { + return { + kind: 'description', + id: 'kd_1', + anchorType: 'tag', + anchorKey: 'frm_V1StGXR8Z5jdHi6BmyT7K:m1', + anchorLabel: 'Add to cart', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + frameName: 'Cart', + flowId: 'flow_1', + subjectKey: 'product.add', + spatial: { at: { x: 0.2, y: 0.4 }, element: { css: 'button' } }, + validity: { tier: 'none' }, + freshness: 'unknown', + author: { kind: 'user', id: 'user_1', label: 'Ayla' }, + source: 'tag_mode', + updatedAt: '2026-09-01T00:00:00.000Z', + body: 'Fires on the CTA', + }; +} + +const withProject = (overrides: Partial = {}) => + createHubManageToolSpec( + stubClient({ getDefaultProject: () => 'proj_1', ...overrides }), + ); + +describe('hub_manage', () => { + it('is one action tool with the pinned name and non-idempotent annotations', () => { + const spec = withProject(); + expect(spec.name).toBe('hub_manage'); + expect(spec.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }); + }); + + it('rejects an unknown action with INVALID_INPUT', async () => { + const result = await withProject().handler({ + action: 'delete', + flowId: 'flow_1', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('asks for a project when there is none to fall back to', async () => { + const result = await createHubManageToolSpec(stubClient()).handler({ + action: 'releases', + flowId: 'flow_1', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('refuses a null rationale instead of clearing the note', async () => { + const result = await withProject().handler({ + action: 'rationale_set', + flowId: 'flow_1', + versionId: 'ver_a', + text: null, + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('refuses whitespace-only text', async () => { + const result = await withProject().handler({ + action: 'note_add', + flowId: 'flow_1', + threadId: 'thr_1', + text: ' ', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('refuses a page anchor', async () => { + const result = await withProject().handler({ + action: 'note_add', + flowId: 'flow_1', + anchorType: 'page', + anchorKey: 'x', + text: 'hi', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('passes a feature denial through with the hub hint', async () => { + const result = await withProject({ + listReleases: async () => { + throw new CodedError( + 'hub is not available on your current plan', + 'FEATURE_NOT_AVAILABLE', + ); + }, + }).handler({ action: 'releases', flowId: 'flow_1' }); + expect(structured(result)).toMatchObject({ + code: 'FEATURE_NOT_AVAILABLE', + hint: featureDenialHint('hub'), + }); + }); + + describe('releases', () => { + it('renames the spine fields and wraps the rationale first line', async () => { + const result = await withProject({ + listReleases: async () => ({ + releases: [ + release({ + rationale: { + hasHumanText: true, + hasGeneratedSummary: false, + firstLine: 'Because', + }, + }), + ], + total: 1, + limit: 20, + offset: 0, + }), + }).handler({ action: 'releases', flowId: 'flow_1' }); + expect(structured(result)).toMatchObject({ + total: 1, + releases: [ + { + versionId: 'ver_a', + versionNumber: 14, + deployment: 'shop-web', + deploymentType: 'web', + deploymentAttempt: 3, + status: 'active', + source: 'app', + errorCode: null, + createdAt: '2026-09-01T00:00:00.000Z', + createdBy: 'user_1', + rationale: { + hasHumanText: true, + hasGeneratedSummary: false, + firstLine: 'Because', + }, + }, + ], + }); + expect(hintsOf(result)).toEqual([ + HUB_HINT_RELEASE_GET, + HUB_HINT_ROWS_ARE_DEPLOYMENTS, + HUB_HINT_STEP_HISTORY, + ]); + }); + + it('treats an absent rationale summary as none', async () => { + const result = await withProject({ + listReleases: async () => ({ + releases: [release({ rationale: undefined })], + total: 1, + limit: 20, + offset: 0, + }), + }).handler({ action: 'releases', flowId: 'flow_1' }); + expect(structured(result)).toMatchObject({ + releases: [{ rationale: null }], + }); + }); + }); + + describe('release_get', () => { + it('requires an address', async () => { + const result = await withProject().handler({ + action: 'release_get', + flowId: 'flow_1', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('wraps the diff text and hints at writing a rationale when there is none', async () => { + const result = await withProject({ + getRelease: async () => detail(), + }).handler({ + action: 'release_get', + flowId: 'flow_1', + versionNumber: 14, + }); + expect(structured(result)).toMatchObject({ + versionId: 'ver_a', + diff: { + prevVersionId: 'ver_p', + prevVersionNumber: 13, + text: '- id: G-1\n+ id: G-2', + contentIdentical: false, + note: null, + }, + diffUnavailable: null, + }); + expect(hintsOf(result)).toEqual([HUB_HINT_WRITE_RATIONALE]); + }); + + it('does not call a masked-only change identical', async () => { + const result = await withProject({ + getRelease: async () => + detail({ + diff: { + prevVersionId: 'ver_p', + prevVersionNumber: 13, + text: '', + contentIdentical: false, + }, + }), + }).handler({ + action: 'release_get', + flowId: 'flow_1', + versionId: 'ver_a', + }); + expect(structured(result)).toMatchObject({ + diff: { text: null, contentIdentical: false }, + }); + expect(record(structured(result).diff).note).toContain('masked'); + expect(hintsOf(result)).toEqual([HUB_HINT_MASKED_ONLY]); + }); + + it('says why the oldest release has no diff and points at step history when rationale exists', async () => { + const result = await withProject({ + getRelease: async () => + detail({ + diff: null, + rationale: { + versionId: 'ver_a', + humanText: 'why', + generatedSummary: null, + author: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + }, + }), + }).handler({ + action: 'release_get', + flowId: 'flow_1', + versionId: 'ver_a', + }); + expect(structured(result)).toMatchObject({ + diff: null, + rationale: { humanText: 'why' }, + }); + expect(typeof structured(result).diffUnavailable).toBe('string'); + expect(hintsOf(result)).toEqual([HUB_HINT_TRACE_STEP]); + }); + + it('passes NOT_FOUND through with the discovery hint', async () => { + const result = await withProject({ + getRelease: async () => { + throw new CodedError('Release not found', 'NOT_FOUND'); + }, + }).handler({ + action: 'release_get', + flowId: 'flow_1', + versionId: 'ver_x', + }); + expect(structured(result)).toMatchObject({ + code: 'NOT_FOUND', + error: 'Release not found', + }); + expect(typeof structured(result).hint).toBe('string'); + }); + }); + + describe('step_history', () => { + it('requires a step', async () => { + const result = await withProject().handler({ + action: 'step_history', + flowId: 'flow_1', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('leaves the rationale text out of the scan', async () => { + const listStepHistory = jest.fn(async () => stepHistory()); + const result = await withProject({ listStepHistory }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.ga4', + }); + expect(listStepHistory).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + step: 'destination.ga4', + }); + // The scan is an index: release_get is the detail read. An entry that + // carried the note would put unwrapped user text in model context, so + // the surfaced shape is pinned exhaustively and both text fields are + // named as the ones that must never appear. + const entry = rows(structured(result).entries)[0]; + expect(entry).toEqual({ + versionId: 'ver_a', + versionNumber: 14, + createdAt: '2026-09-01T00:00:00.000Z', + flow: 'web', + change: 'changed', + }); + expect(entry).not.toHaveProperty('humanText'); + expect(entry).not.toHaveProperty('generatedSummary'); + }); + + const scanCases: Array<{ + label: string; + history: Partial; + hint: string; + }> = [ + { + label: 'stopped at the entry cap', + history: { entriesTruncated: true }, + hint: HUB_HINT_SCAN_CAPPED, + }, + { + label: 'matched nothing', + history: { entries: [] }, + hint: HUB_HINT_NO_MATCH, + }, + { + label: 'found a release that touched the step', + history: {}, + hint: HUB_HINT_OPEN_RELEASE, + }, + ]; + + it.each(scanCases)( + 'points somewhere useful when the scan $label', + async ({ history, hint }) => { + const result = await withProject({ + listStepHistory: async () => stepHistory(history), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.ga4', + }); + expect(hintsOf(result)).toEqual([hint]); + }, + ); + }); + + describe('rationale_set', () => { + it('resolves a versionNumber through getRelease before writing', async () => { + const setReleaseRationale = jest.fn(async () => ({ + versionId: 'ver_a', + humanText: 'why', + generatedSummary: null, + author: 'user_1', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + })); + const result = await withProject({ + getRelease: async () => detail(), + setReleaseRationale, + }).handler({ + action: 'rationale_set', + flowId: 'flow_1', + versionNumber: 14, + text: 'why', + }); + expect(setReleaseRationale).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + versionId: 'ver_a', + text: 'why', + }); + expect(structured(result)).toMatchObject({ + versionId: 'ver_a', + versionNumber: 14, + rationale: { humanText: 'why' }, + }); + }); + + it('refuses a release id belonging to a sibling flow before writing', async () => { + const setReleaseRationale = jest.fn(); + const result = await withProject({ + getRelease: async () => { + throw new CodedError('Release not found', 'NOT_FOUND'); + }, + setReleaseRationale, + }).handler({ + action: 'rationale_set', + flowId: 'flow_1', + versionId: 'ver_other', + text: 'why', + }); + expect(structured(result)).toMatchObject({ code: 'NOT_FOUND' }); + expect(setReleaseRationale).not.toHaveBeenCalled(); + }); + }); + + describe('threads', () => { + it('reads a whole-flow index without message bodies', async () => { + const listThreads = jest.fn(async () => ({ + threads: [thread()], + hasMoreThreads: true, + })); + const result = await withProject({ listThreads }).handler({ + action: 'threads', + flowId: 'flow_1', + limit: 500, + }); + expect(listThreads).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + includeMessages: false, + limit: 100, + }); + expect(structured(result)).toMatchObject({ + hasMoreThreads: true, + threads: [ + { threadId: 'thr_1', anchorLabel: 'v14' }, + ], + }); + expect(rows(structured(result).threads)[0]).not.toHaveProperty( + 'messages', + ); + }); + + it('resolves a release anchor through getRelease and attaches bodies', async () => { + const listThreads = jest.fn(async () => ({ + threads: [ + thread({ + messages: [ + { + id: 'msg_1', + author: 'user_1', + text: 'ok?', + createdAt: '2026-09-01T00:00:00.000Z', + }, + ], + hasMoreMessages: true, + }), + ], + hasMoreThreads: false, + })); + const result = await withProject({ + getRelease: async () => detail(), + listThreads, + }).handler({ action: 'threads', flowId: 'flow_1', versionNumber: 14 }); + expect(listThreads).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + includeMessages: true, + anchorType: 'release', + anchorKey: 'ver_a', + }); + expect(structured(result)).toMatchObject({ + threads: [ + { + hasMoreMessages: true, + messages: [ + { author: 'user_1', text: 'ok?' }, + ], + }, + ], + }); + }); + }); + + describe('note_add', () => { + it('replies into the named thread', async () => { + const addThreadMessage = jest.fn(async () => thread({ messageCount: 2 })); + const result = await withProject({ addThreadMessage }).handler({ + action: 'note_add', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'reply', + }); + expect(addThreadMessage).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + threadId: 'thr_1', + text: 'reply', + }); + expect(structured(result)).toMatchObject({ + thread: { threadId: 'thr_1', messageCount: 2 }, + }); + }); + + it('opens a thread on a release addressed by number', async () => { + const createThread = jest.fn(async () => thread()); + await withProject({ + getRelease: async () => detail(), + createThread, + }).handler({ + action: 'note_add', + flowId: 'flow_1', + versionNumber: 14, + text: 'hi', + }); + expect(createThread).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'release', + anchorKey: 'ver_a', + text: 'hi', + }); + }); + + it('opens a thread on a step anchor with the given label', async () => { + const createThread = jest.fn(async () => + thread({ + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'GA4', + }), + ); + await withProject({ createThread }).handler({ + action: 'note_add', + flowId: 'flow_1', + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'GA4', + text: 'hi', + }); + expect(createThread).toHaveBeenCalledWith({ + projectId: 'proj_1', + flowId: 'flow_1', + anchorType: 'step', + anchorKey: 'destination.ga4', + anchorLabel: 'GA4', + text: 'hi', + }); + }); + + it('refuses a note with nowhere to go', async () => { + const result = await withProject().handler({ + action: 'note_add', + flowId: 'flow_1', + text: 'hi', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + }); + + describe('knowledge', () => { + it('refuses a flowId rather than answering an unnarrowed page', async () => { + const result = await withProject().handler({ + action: 'knowledge', + flowId: 'flow_1', + }); + expect(structured(result)).toMatchObject({ code: 'INVALID_INPUT' }); + }); + + it('attaches bodies only for one mark and drops the DOM anchor', async () => { + const listKnowledge = jest.fn(async () => ({ + entries: [description()], + hasMoreEntries: false, + })); + const result = await withProject({ listKnowledge }).handler({ + action: 'knowledge', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + markId: 'm1', + }); + expect(listKnowledge).toHaveBeenCalledWith({ + projectId: 'proj_1', + includeMessages: true, + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + markId: 'm1', + }); + const entry = rows(structured(result).entries)[0]; + expect(entry).toMatchObject({ + kind: 'description', + frameId: 'frm_V1StGXR8Z5jdHi6BmyT7K', + frameName: 'Cart', + body: 'Fires on the CTA', + author: { label: 'Ayla' }, + }); + expect(entry).not.toHaveProperty('spatial'); + expect(hintsOf(result)).toContain(HUB_HINT_READ_FRAME); + }); + + it('says when nothing has been written', async () => { + const result = await withProject({ + listKnowledge: async () => ({ entries: [], hasMoreEntries: false }), + }).handler({ action: 'knowledge' }); + expect(hintsOf(result)).toEqual([HUB_HINT_NOTHING_WRITTEN]); + }); + }); + + // A link belongs in the structured result, not only in prose: an agent + // reads it as data and hands it on without retyping it out of a sentence. + describe('links into the app', () => { + it('links the release history a release index is of', async () => { + const result = await withProject({ + listReleases: async () => ({ + releases: [release()], + total: 1, + limit: 20, + offset: 0, + }), + }).handler({ action: 'releases', flowId: 'flow_1' }); + expect(structured(result).appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_1?view=releases', + ); + }); + + it('links the step a scan was narrowed to one flow by', async () => { + const result = await withProject({ + listStepHistory: async () => stepHistory({ flow: 'web' }), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.ga4', + flow: 'web', + }); + expect(structured(result).appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_1?view=step&flow=web&step=destination.ga4', + ); + }); + + it('links nothing for a scan that named no flow', async () => { + // `flow` on the response is the caller's own filter echoed back, and a + // step address without one opens nothing in the app. + const result = await withProject({ + listStepHistory: async () => stepHistory(), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.ga4', + }); + expect(structured(result)).not.toHaveProperty('appUrl'); + }); + + // The filter is echoed back unvalidated, so the scan's own result is the + // only evidence in hand that the step is still there. Both of these are + // what the app answers with its "not found in this flow" notice. + it('links nothing when the scan matched no release', async () => { + const result = await withProject({ + listStepHistory: async () => stepHistory({ flow: 'web', entries: [] }), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.typo', + flow: 'web', + }); + expect(structured(result)).not.toHaveProperty('appUrl'); + }); + + it('links nothing when the newest release removed the step', async () => { + const result = await withProject({ + listStepHistory: async () => + stepHistory({ + flow: 'web', + entries: [ + { + versionId: 'ver_b', + versionNumber: 15, + createdAt: '2026-09-02T00:00:00.000Z', + flow: 'web', + change: 'removed', + humanText: null, + generatedSummary: null, + }, + { + versionId: 'ver_a', + versionNumber: 14, + createdAt: '2026-09-01T00:00:00.000Z', + flow: 'web', + change: 'added', + humanText: null, + generatedSummary: null, + }, + ], + }), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'destination.ga4', + flow: 'web', + }); + expect(structured(result)).not.toHaveProperty('appUrl'); + }); + + it('sends a contract step to the contract view, which opens', async () => { + const result = await withProject({ + listStepHistory: async () => stepHistory({ step: 'contract.checkout' }), + }).handler({ + action: 'step_history', + flowId: 'flow_1', + step: 'contract.checkout', + }); + expect(structured(result).appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_1?view=contract', + ); + }); + + it('links the release history for a release-anchored discussion', async () => { + const result = await withProject({ + getRelease: async () => detail(), + listThreads: async () => ({ + threads: [thread()], + hasMoreThreads: false, + }), + }).handler({ action: 'threads', flowId: 'flow_1', versionId: 'ver_a' }); + expect(structured(result).appUrl).toBe( + 'https://app.walkeros.io/projects/proj_1/flows/flow_1?view=releases', + ); + }); + + it('links nothing for a step-anchored discussion, which has no screen', async () => { + const result = await withProject({ + listThreads: async () => ({ + threads: [ + thread({ anchorType: 'step', anchorKey: 'destination.ga4' }), + ], + hasMoreThreads: false, + }), + }).handler({ + action: 'threads', + flowId: 'flow_1', + anchorType: 'step', + anchorKey: 'destination.ga4', + }); + expect(structured(result)).not.toHaveProperty('appUrl'); + }); + }); +}); diff --git a/packages/mcps/mcp/src/__tests__/tools/project-manage-user-data.test.ts b/packages/mcps/mcp/src/__tests__/tools/project-manage-user-data.test.ts index 11ab751b9..18a10f592 100644 --- a/packages/mcps/mcp/src/__tests__/tools/project-manage-user-data.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/project-manage-user-data.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from '@jest/globals'; import { createProjectManageToolSpec } from '../../tools/project-manage'; +import { stubClient } from '../support/stub-client.js'; import type { ToolClient } from '../../tool-client'; function makeClient(overrides: Partial = {}): ToolClient { - const base = { + const base: Partial = { listProjects: async () => [ { id: 'p_1', name: 'Acme ' }, { id: 'p_2', name: 'Beta' }, @@ -14,7 +15,7 @@ function makeClient(overrides: Partial = {}): ToolClient { deleteProject: async () => ({ ok: true }), setDefaultProject: () => undefined, }; - return { ...base, ...overrides } as unknown as ToolClient; + return stubClient({ ...base, ...overrides }); } describe('project_manage wraps user-writable project.name', () => { diff --git a/packages/mcps/mcp/src/__tests__/tools/secret-manage.test.ts b/packages/mcps/mcp/src/__tests__/tools/secret-manage.test.ts index c99160cee..f1d016480 100644 --- a/packages/mcps/mcp/src/__tests__/tools/secret-manage.test.ts +++ b/packages/mcps/mcp/src/__tests__/tools/secret-manage.test.ts @@ -135,7 +135,7 @@ describe('secret_manage tool', () => { expect(result.isError).toBe(true); const parsed = JSON.parse(result.content[0].text) as { error: string }; - expect(parsed.error).toContain('No default project set'); + expect(parsed.error).toContain('No project selected'); expect(listSecrets).not.toHaveBeenCalled(); }); }); diff --git a/packages/mcps/mcp/src/action-requirements.ts b/packages/mcps/mcp/src/action-requirements.ts index 38f95df2d..dae6bfb69 100644 --- a/packages/mcps/mcp/src/action-requirements.ts +++ b/packages/mcps/mcp/src/action-requirements.ts @@ -156,3 +156,14 @@ export const PROJECT_MANAGE_REQUIREMENTS: ActionRequirementMap = { }, create: { required: ['name'] }, }; + +export const FRAME_MANAGE_REQUIREMENTS: ActionRequirementMap = { + page: { + required: ['pageKey'], + hint: 'Use action "list" to see the source keys of the project’s frames.', + }, + get: { + required: ['frameId'], + hint: 'Use action "list" or "page" to find a frameId.', + }, +}; diff --git a/packages/mcps/mcp/src/base-url.ts b/packages/mcps/mcp/src/base-url.ts new file mode 100644 index 000000000..b9ab3992e --- /dev/null +++ b/packages/mcps/mcp/src/base-url.ts @@ -0,0 +1,41 @@ +/** + * One spelling of a walkerOS app base URL, so every consumer concatenates a + * path onto the same shape. + * + * The two doors resolve their base from different worlds and neither + * guarantees the shape on its own: the local door hands back whatever + * `WALKEROS_APP_URL` or the CLI config file holds, and a valid URL may carry a + * trailing slash, a query string or a fragment. Normalizing at the seam is what + * keeps a link built from one door from reading + * `https://app.example.com//projects/...` while the same link from the other + * door reads cleanly. + * + * A query or a fragment is worse than a stray slash, because a path appended to + * one lands INSIDE it: `https://app.example.com?a=b` plus `/projects/p1` reads + * `https://app.example.com?a=b/projects/p1`, which is a broken link that a tool + * then puts in somebody's chat transcript. Neither belongs on a base URL, so + * both are dropped. The path is kept: an app mounted under a subpath is a real + * deployment, and dropping it would break every link instead of fixing one. + * + * This never throws, deliberately. Its callers include `diagnostics`, whose job + * is to NAME a misconfigured app URL; a normalizer that threw would take out + * the tool that exists to report the problem. A string that does not parse as a + * URL is handed back with only its trailing slashes trimmed, and fails later at + * the request, where the error names the URL. + */ +export function normalizeBaseUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return stripTrailingSlashes(url); + } + + parsed.search = ''; + parsed.hash = ''; + return stripTrailingSlashes(parsed.toString()); +} + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} diff --git a/packages/mcps/mcp/src/http-tool-client.ts b/packages/mcps/mcp/src/http-tool-client.ts index 5d261a402..b27a75824 100644 --- a/packages/mcps/mcp/src/http-tool-client.ts +++ b/packages/mcps/mcp/src/http-tool-client.ts @@ -30,19 +30,30 @@ import { startObserveSession, getObserveSession, endObserveSession, - requestDeviceCode, - pollForToken, + listReleases, + getRelease, + listStepHistory, + setReleaseRationale, + listThreads, + createThread, + addThreadMessage, + listKnowledge, + listFrames, + listPageFrames, + getFrame, + startDeviceAuthorization, + completeDeviceLogin, whoami, - resolveToken, + credentialSource, resolveAppUrl, - deleteConfig, + logout, feedback, getFeedbackPreference, setFeedbackPreference, } from '@walkeros/cli'; import type { - DeviceCodeResult, - PollResult, + DeviceAuthorization, + DeviceLoginResult, ListFlowsOptions, DeployOptions, ListDeploymentsOptions, @@ -57,6 +68,7 @@ import type { FeedbackOptions, } from '@walkeros/cli'; +import { normalizeBaseUrl } from './base-url.js'; import type { ToolClient, JourneysResult, @@ -64,6 +76,19 @@ import type { ObserveSessionResult, ObserveSessionRef, StartObserveSessionOptions, + ReleaseRef, + ReleaseIndexWire, + ReleaseDetailWire, + StepHistoryWire, + VersionAnnotationWire, + ThreadAnchorType, + ThreadStatus, + HubThreadWire, + ListThreadsWire, + ListKnowledgeWire, + FrameWire, + FrameListWire, + FrameLeanListWire, } from './tool-client.js'; /** @@ -234,29 +259,128 @@ export class HttpToolClient implements ToolClient { return endObserveSession(options); } - async requestDeviceCode(): Promise { - return requestDeviceCode(); + async listReleases(options: { + projectId: string; + flowId: string; + limit?: number; + offset?: number; + }): Promise { + return listReleases(options); + } + async getRelease(options: { + projectId: string; + flowId: string; + ref: ReleaseRef; + }): Promise { + return getRelease(options); + } + async listStepHistory(options: { + projectId: string; + flowId: string; + step: string; + flow?: string; + limit?: number; + }): Promise { + return listStepHistory(options); + } + async setReleaseRationale(options: { + projectId: string; + flowId: string; + versionId: string; + text: string; + }): Promise { + return setReleaseRationale(options); + } + async listThreads(options: { + projectId: string; + flowId: string; + anchorType?: ThreadAnchorType; + anchorKey?: string; + status?: ThreadStatus; + includeMessages: boolean; + limit?: number; + }): Promise { + return listThreads(options); + } + async createThread(options: { + projectId: string; + flowId: string; + anchorType: ThreadAnchorType; + anchorKey: string; + anchorLabel?: string; + text: string; + }): Promise { + return createThread(options); + } + async addThreadMessage(options: { + projectId: string; + flowId: string; + threadId: string; + text: string; + }): Promise { + return addThreadMessage(options); + } + async listKnowledge(options: { + projectId: string; + pageKey?: string; + frameId?: string; + markId?: string; + includeMessages: boolean; + limit?: number; + }): Promise { + return listKnowledge(options); + } + + async listFrames(options: { projectId: string }): Promise { + return listFrames(options); + } + async listPageFrames(options: { + projectId: string; + pageKey: string; + }): Promise { + return listPageFrames(options); + } + async getFrame(options: { + projectId: string; + frameId: string; + }): Promise { + return getFrame(options); + } + + async requestDeviceCode(): Promise { + return startDeviceAuthorization(resolveAppUrl()); } async pollForToken( deviceCode: string, options?: { timeoutMs?: number }, - ): Promise { - return pollForToken(deviceCode, options); + ): Promise { + return completeDeviceLogin(deviceCode, options); } async whoami(): Promise { return whoami(); } - resolveToken(): { token: string; source: 'env' | 'config' } | null { - return resolveToken(); + credentialSource(): 'env' | 'config' | null { + return credentialSource(); + } + async logout(): Promise<{ deleted: boolean }> { + return logout(); } - deleteConfig(): boolean { - return deleteConfig(); + + /** + * The app this local door talks to: `WALKEROS_APP_URL`, then the CLI config + * file, then the built-in default, which is exactly the chain every other + * method here already resolves its base URL through. Normalized, because + * neither the env var nor the config file is obliged to omit a trailing + * slash and the interface promises a base without one. + */ + appBaseUrl(): string { + return normalizeBaseUrl(resolveAppUrl()); } /** * Unauthenticated reachability probe of the app's PUBLIC `/api/health` - * route. Uses a plain `fetch` (no `createApiClient`, which throws when no - * token is set) so diagnostics works logged-out. Resolves + * route. Uses a plain `fetch` (no `createApiClient`, whose every request + * rejects without a credential) so diagnostics works logged-out. Resolves * `{ reachable: false }` only on a real network/timeout failure. */ async checkHealth(): Promise<{ diff --git a/packages/mcps/mcp/src/index.ts b/packages/mcps/mcp/src/index.ts index 599e810f4..d172ac338 100644 --- a/packages/mcps/mcp/src/index.ts +++ b/packages/mcps/mcp/src/index.ts @@ -14,6 +14,8 @@ import { createDeployManageToolSpec } from './tools/deploy-manage.js'; import { createSecretManageToolSpec } from './tools/secret-manage.js'; import { createObserveJourneysToolSpec } from './tools/observe-journeys.js'; import { createObserveSessionToolSpec } from './tools/observe-session.js'; +import { createHubManageToolSpec } from './tools/hub-manage.js'; +import { createFrameManageToolSpec } from './tools/frame-manage.js'; import { createFeedbackToolSpec } from './tools/feedback.js'; import { createFlowValidateToolSpec } from './tools/validate.js'; @@ -33,7 +35,23 @@ export { type CreateServerOptions, type Logger, } from './server.js'; -export type { ToolClient, JourneysResult } from './tool-client.js'; +export type { + ToolClient, + JourneysResult, + ReleaseRef, + ReleaseIndexWire, + ReleaseDetailWire, + StepHistoryWire, + VersionAnnotationWire, + HubThreadWire, + ListThreadsWire, + KnowledgeEntryWire, + ListKnowledgeWire, + FrameWire, + FrameLeanWire, + FrameListWire, + FrameLeanListWire, +} from './tool-client.js'; export { HttpToolClient } from './http-tool-client.js'; export { createStreamableHttpHandler, @@ -62,6 +80,75 @@ export { HINT_NO_WINDOW, } from './tools/observe-session.js'; +/** + * The `hub_manage` description and next-hints, on the same rule as + * `observe_session` above: a host asserts parity against these strings rather + * than retyping them. + */ +export { + HUB_MANAGE_DESCRIPTION, + HUB_MANAGE_INPUT_SCHEMA, + HUB_HINT_RELEASE_GET, + HUB_HINT_ROWS_ARE_DEPLOYMENTS, + HUB_HINT_STEP_HISTORY, + HUB_HINT_MASKED_ONLY, + HUB_HINT_TRACE_STEP, + HUB_HINT_WRITE_RATIONALE, + HUB_HINT_SCAN_CAPPED, + HUB_HINT_NO_MATCH, + HUB_HINT_OPEN_RELEASE, + HUB_HINT_RATIONALE_VISIBLE, + HUB_HINT_CONFIRM_INDEX, + HUB_HINT_THREADS_PAGE_CAPPED, + HUB_HINT_NOTHING_DISCUSSED, + HUB_HINT_NO_THREAD_ON_ANCHOR, + HUB_HINT_THREADS_INDEX, + HUB_HINT_MESSAGES_TRUNCATED, + HUB_HINT_REPLY_OR_OPEN, + HUB_HINT_RESOLVE_IN_APP, + HUB_HINT_MESSAGE_VISIBLE, + HUB_HINT_STAYS_RESOLVED, + HUB_HINT_READ_BACK, + HUB_HINT_THREAD_OPEN, + HUB_HINT_KEEP_ONE_THREAD, + HUB_HINT_KNOWLEDGE_PAGE_CAPPED, + HUB_HINT_NOTHING_WRITTEN, + HUB_HINT_KNOWLEDGE_INDEX, + HUB_HINT_ENTRY_NAMES_FLOW, + HUB_HINT_KNOWLEDGE_READ_ONLY, + HUB_HINT_READ_FRAME, + HUB_NOT_FOUND_HINT, +} from './tools/hub-manage.js'; + +/** + * The `frame_manage` description, input schema and next-hints, on the same + * rule as `hub_manage` above: a host asserts parity against these strings + * rather than retyping them. + */ +export { + FRAME_MANAGE_DESCRIPTION, + FRAME_MANAGE_INPUT_SCHEMA, + FRAME_HINT_OPEN_PAGE_OR_GET, + FRAME_HINT_NAMES_ARE_DOCUMENTATION, + FRAME_HINT_NONE_YET, + FRAME_HINT_MARK_SPACE, + FRAME_HINT_READ_KNOWLEDGE, + FRAME_HINT_NONE_ON_PAGE, + FRAME_HINT_EXTENDS_BASE, + FRAME_NOT_FOUND_HINT, +} from './tools/frame-manage.js'; + +/** + * The shared feature gate. A door refuses a gated tool with + * `FEATURE_NOT_AVAILABLE`, and every gated tool turns that into the same hint. + */ +export { + FEATURE_NOT_AVAILABLE, + isFeatureDenial, + featureDenialHint, + type GatedFeature, +} from './tools/feature-gate.js'; + export { wrapUserData, redactNestedStrings, @@ -76,6 +163,19 @@ export { type SuggestionTile, } from './ui-parts.js'; +/** + * Addresses of app screens. Published because the app depends on this package + * and never the reverse, so this is the only place a link definition can live + * that both the tools and an app-side caller can reach. + */ +export { + links, + type FlowLinkTarget, + type StepLinkTarget, + type ThreadLinkTarget, + type DeploymentLinkTarget, +} from './links.js'; + /** * Handler-bearing spec for every tool `createWalkerOSMcpServer` registers. * @@ -99,6 +199,8 @@ export function createToolHandlers( createSecretManageToolSpec(client), createObserveSessionToolSpec(client), createObserveJourneysToolSpec(client), + createHubManageToolSpec(client), + createFrameManageToolSpec(client), createFeedbackToolSpec(client), createFlowValidateToolSpec(), createFlowBundleToolSpec(client), diff --git a/packages/mcps/mcp/src/instructions.ts b/packages/mcps/mcp/src/instructions.ts index af38ab47f..7df477b87 100644 --- a/packages/mcps/mcp/src/instructions.ts +++ b/packages/mcps/mcp/src/instructions.ts @@ -25,6 +25,8 @@ export const SERVER_INSTRUCTIONS = `walkerOS is an open-source, privacy-first ev 13. \`observe_session({ action: "start", flowId: "..." })\` - open an Observe session: a time-boxed window on one flow that runtimes attach to as arms 14. \`flow_manage({ action: "preview_regrant", flowId: "...", previewId: "...", origins: [...] })\` - mint an activation link; minted while the flow is observed, it pairs with that Observe session automatically, so the previewed page streams into the same feed 15. \`observe_journeys({ flowId: "..." })\` - read what arrived; it is the only read, and it never judges whether events are correct +16. \`hub_manage({ action: "releases", flowId: "..." })\` - read what changed across releases and why; \`release_get\` carries a server-computed diff, \`rationale_set\` records why +17. \`frame_manage({ action: "page", pageKey: "..." })\` - read the frames of a page with their marks; read-only, edited in Tag Mode or the app ## Architecture: Source → Collector → Destination(s) diff --git a/packages/mcps/mcp/src/links.ts b/packages/mcps/mcp/src/links.ts new file mode 100644 index 000000000..f4aa4ebd2 --- /dev/null +++ b/packages/mcps/mcp/src/links.ts @@ -0,0 +1,187 @@ +/** + * Addresses of walkerOS app screens, built in one place so every tool hands a + * person the same link for the same screen. + * + * WHY THIS EXISTS. A tool that names a release, a step, or a deployment in + * prose leaves the person to go find it. A link ends that: the answer and the + * screen it is about arrive together. Building the address in each tool would + * instead give as many spellings of `/projects/.../flows/...` as there are + * emission points, and the first one to drift would send someone to a 404 that + * no test here could see. + * + * THE BASE URL IS CONSUMED RAW. `client.appBaseUrl()` is already normalized by + * the door that answers it (the CLI-backed door wraps `resolveAppUrl()` in + * `normalizeBaseUrl`; the hosted door returns the same expression it publishes + * as its OAuth issuer, which cannot carry a trailing slash). Normalizing again + * here would put a second opinion about the shape in the system, which is + * exactly what one normalization point per door exists to prevent. + * + * ONE DEFINITION, BOTH DOORS. Nothing here branches on which door is calling. + * The base URL is the only thing that differs between them, and it arrives as + * an argument. + * + * EVERY LINK IS ABSOLUTE. These land in a chat transcript, where a path + * relative to nothing is useless. + * + * AN UNBUILDABLE LINK IS `undefined`, NEVER A GUESS. Every builder returns + * `undefined` when it cannot name a screen that exists, and callers spread the + * field conditionally so the result simply carries no link. A link that lands + * on a 404, or on the app's "can't open that" notice, is worse than no link: + * it costs the person a click and teaches them the tool's links are unreliable. + * + * THE URL SHAPES ARE THE APP'S, NOT OURS. They mirror what the app routes + * today: `/projects/{projectId}/flows/{flowId}` and + * `/projects/{projectId}/deployments/{deploymentId}`, with a view on the flow + * page addressed by the `view` query param whose value names a SUBJECT + * (`variables`, `secrets`, `contract`, `history`, `releases`, `knowledge`, + * `step`). A step is `?view=step&flow=&step=`, the same + * vocabulary `hub_manage` already takes as its `flow` and `step` params. + */ + +/** A flow page: the address every flow-scoped view hangs off. */ +export interface FlowLinkTarget { + /** From `client.appBaseUrl()`. Consumed as given. */ + baseUrl: string; + projectId: string; + flowId: string; +} + +/** + * One step inside a flow. `flow` is the named flow within the config ("web", + * "server"); `step` is `"type.name"`, e.g. `"destination.ga4"`. + */ +export interface StepLinkTarget extends FlowLinkTarget { + step: string; + /** + * Null and undefined both mean "the caller did not name one", which is the + * shape `hub_manage`'s step history answers with when it was not filtered by + * flow. + */ + flow?: string | null; +} + +/** One discussion thread, addressed by what it hangs on. */ +export interface ThreadLinkTarget extends FlowLinkTarget { + anchorType: string; +} + +/** + * One deployment. + * + * Pass the `dep_...` id, not the slug. The detail route resolves either, but + * the page's live-status stream matches on the id alone, so a slug link opens a + * page whose status stream fails while the deployment is still deploying. This + * builder does not inspect the value: naming the id is the caller's job. + */ +export interface DeploymentLinkTarget { + baseUrl: string; + projectId: string; + deploymentId: string; +} + +/** + * Contract entries are top-level and carry no named flow, so they are not + * `?view=step` addresses. + */ +const CONTRACT_PREFIX = 'contract.'; + +/** Every address component has to be a non-empty string, or there is no link. */ +function addressable(...parts: string[]): boolean { + return parts.every((part) => part !== ''); +} + +function flowPage(target: FlowLinkTarget): string | undefined { + const { baseUrl, projectId, flowId } = target; + if (!addressable(baseUrl, projectId, flowId)) return undefined; + return `${baseUrl}/projects/${encodeURIComponent(projectId)}/flows/${encodeURIComponent(flowId)}`; +} + +/** + * A view on the flow page. `view` leads because it is the primary key: the + * other params only mean anything beside it. + */ +function flowView( + target: FlowLinkTarget, + view: string, + params: Record = {}, +): string | undefined { + const page = flowPage(target); + if (page === undefined) return undefined; + const query = new URLSearchParams({ view, ...params }); + return `${page}?${query.toString()}`; +} + +export const links = { + /** The flow page itself. `undefined` when any part of the address is empty. */ + flow(target: FlowLinkTarget): string | undefined { + return flowPage(target); + }, + + /** + * The screen showing one step. + * + * A `contract` step, in any of its spellings, is answered with the CONTRACT + * view rather than a step address. `?view=step&step=contract.checkout` is a + * legitimate address in the vocabulary this tool speaks, but the app has + * deliberately deferred opening ONE contract entry and refuses that link with + * a notice. `?view=contract` opens the contract editor the entry lives in, + * which is the nearest screen that actually exists. A `flow` alongside a + * contract step is a caller mistake (contract entries are top-level) and is + * ignored rather than turned into a refused link. + * + * Otherwise a step needs its named flow: `type.name` alone is not unique in a + * config holding both a web and a server flow, and the app resolves an + * address without one to nothing and says so on screen. So an unnamed flow + * yields `undefined` here. Callers that want the link can re-ask with `flow`. + * + * What this cannot check is whether the step is still IN that flow. The + * address is resolved against the live config when the page opens, and a step + * since renamed or removed gets a notice there rather than a broken screen. + */ + step(target: StepLinkTarget): string | undefined { + const { step, flow } = target; + if (step === '') return undefined; + if (step === 'contract' || step.startsWith(CONTRACT_PREFIX)) + return flowView(target, 'contract'); + if (typeof flow !== 'string' || flow === '') return undefined; + return flowView(target, 'step', { flow, step }); + }, + + /** + * The release history of a flow. + * + * The app has no address for ONE release: `?view=releases` declares no params + * and opens the list the release is a row of. That is the screen a person + * asking about a release wants to be on, so this takes the flow and nothing + * else rather than pretending to a precision the app does not have. + */ + release(target: FlowLinkTarget): string | undefined { + return flowView(target, 'releases'); + }, + + /** + * The screen a thread is read on. + * + * Only a release anchor can be addressed from the wire shape this tool + * holds. The app renders release-anchored discussions inside the release + * history, beside the release they hang on. Step and entity-action anchors + * have no surface at all yet. A TAG anchor does have one, the knowledge list + * on `?view=knowledge`, but only when the thread was captured in Tag Mode + * and carries a frame: `HubThreadWire` has no `frameId`, so nothing here can + * tell such a thread from one written over MCP against a null frame, and a + * knowledge link would open a list the thread may be absent from. So + * everything but a release anchor gets no link rather than a possibly empty + * one. + */ + thread(target: ThreadLinkTarget): string | undefined { + if (target.anchorType !== 'release') return undefined; + return flowView(target, 'releases'); + }, + + /** One deployment's detail page. */ + deployment(target: DeploymentLinkTarget): string | undefined { + const { baseUrl, projectId, deploymentId } = target; + if (!addressable(baseUrl, projectId, deploymentId)) return undefined; + return `${baseUrl}/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}`; + }, +}; diff --git a/packages/mcps/mcp/src/server.ts b/packages/mcps/mcp/src/server.ts index d2d7b83d6..35de44058 100644 --- a/packages/mcps/mcp/src/server.ts +++ b/packages/mcps/mcp/src/server.ts @@ -20,6 +20,8 @@ import { registerDeployTool } from './tools/deploy-manage.js'; import { registerSecretManageTool } from './tools/secret-manage.js'; import { registerObserveJourneysTool } from './tools/observe-journeys.js'; import { registerObserveSessionTool } from './tools/observe-session.js'; +import { registerHubManageTool } from './tools/hub-manage.js'; +import { registerFrameManageTool } from './tools/frame-manage.js'; import { registerPackageSchemaResources } from './resources/package-schemas.js'; import { registerReferenceResources } from './resources/references.js'; import { registerAddStepPrompt } from './prompts/add-step.js'; @@ -128,6 +130,8 @@ export function createWalkerOSMcpServer(opts: CreateServerOptions): McpServer { registerSecretManageTool(server, opts.client); registerObserveSessionTool(server, opts.client); registerObserveJourneysTool(server, opts.client); + registerHubManageTool(server, opts.client); + registerFrameManageTool(server, opts.client); registerFeedbackTool(server, opts.client); registerDiagnosticsTool(server, opts.client, packageVersion); diff --git a/packages/mcps/mcp/src/tool-client.ts b/packages/mcps/mcp/src/tool-client.ts index 93c2c2ddd..83318ff02 100644 --- a/packages/mcps/mcp/src/tool-client.ts +++ b/packages/mcps/mcp/src/tool-client.ts @@ -1,6 +1,6 @@ import type { - DeviceCodeResult, - PollResult, + DeviceAuthorization, + DeviceLoginResult, ListFlowsOptions, DeployOptions, ListDeploymentsOptions, @@ -126,6 +126,257 @@ export interface ObserveSessionRef { sessionId: string; } +// ---- Hub and frames wire shapes. Each mirrors one app response schema; the +// hosted door serializes through the same functions its routes use, the local +// door hands the parsed JSON through, so a tool sees one shape from both. + +export type ThreadAnchorType = + | 'step' + | 'entity_action' + | 'release' + | 'contract' + | 'tag'; +export type StoredAnchorType = ThreadAnchorType | 'page'; +export type ThreadStatus = 'open' | 'resolved'; +export type ReleaseRef = { versionId: string } | { versionNumber: number }; + +export interface ReleaseRationaleSummaryWire { + hasHumanText: boolean; + hasGeneratedSummary: boolean; + firstLine: string | null; +} + +export interface FlowReleaseWire { + id: string; + deploymentId: string; + deploymentSlug: string | null; + deploymentType: string | null; + versionNumber: number; + flowVersionId: string | null; + flowVersionNumber: number | null; + status: string; + source: string; + errorCode: string | null; + createdAt: string; + createdBy: string | null; + createdByLabel: string | null; + /** Present only on a read that asked for rationale. */ + rationale?: ReleaseRationaleSummaryWire | null; +} + +export interface ReleaseIndexWire { + releases: FlowReleaseWire[]; + total: number; + limit: number; + offset: number; +} + +export interface VersionAnnotationWire { + versionId: string; + humanText: string | null; + generatedSummary: string | null; + author: string; + createdAt: string; + updatedAt: string; +} + +export interface ReleaseDiffWire { + prevVersionId: string; + prevVersionNumber: number; + text: string; + contentIdentical: boolean; +} + +export interface ReleaseDetailWire { + versionId: string; + versionNumber: number; + contentHash: string | null; + createdAt: string; + createdBy: string; + rationale: VersionAnnotationWire | null; + diff: ReleaseDiffWire | null; +} + +export interface StepHistoryEntryWire { + versionId: string; + versionNumber: number; + createdAt: string; + flow: string | null; + change: 'added' | 'removed' | 'changed'; + humanText: string | null; + generatedSummary: string | null; +} + +export interface StepHistoryWire { + step: string; + flow: string | null; + entries: StepHistoryEntryWire[]; + scanned: number; + truncated: boolean; + entriesTruncated: boolean; + knownSteps?: string[]; +} + +export interface HubMessageWire { + id: string; + author: string; + text: string; + createdAt: string; +} + +export interface HubThreadWire { + id: string; + anchorType: ThreadAnchorType; + anchorKey: string; + anchorLabel: string; + status: ThreadStatus; + resolvedByVersionId: string | null; + resolvedByVersionNumber: number | null; + resolvedAt: string | null; + resolvedBy: string | null; + createdBy: string; + createdAt: string; + updatedAt: string; + messageCount: number; + messages?: HubMessageWire[]; + hasMoreMessages?: boolean; +} + +export interface ListThreadsWire { + threads: HubThreadWire[]; + hasMoreThreads: boolean; +} + +export type KnowledgeValidityWire = + | { + tier: 'release'; + versionId: string; + versionNumber: number; + promoted: boolean; + } + | { tier: 'draft'; versionId?: string } + | { tier: 'none' }; + +export interface KnowledgeAuthorWire { + kind: 'user' | 'preview' | 'agent'; + id: string | null; + label: string; +} + +export interface KnowledgeMessageWire { + id: string; + author: string; + authorLabel: string; + text: string; + createdAt: string; + clientMessageId: string | null; +} + +interface KnowledgeEntryBaseWire { + id: string; + anchorKey: string; + anchorLabel: string; + frameId: string | null; + frameName: string | null; + flowId: string | null; + subjectKey: string | null; + /** Opaque DOM anchor plus a fractional point. Never surfaced by a tool. */ + spatial: { + at: { x: number; y: number }; + element?: Record; + } | null; + validity: KnowledgeValidityWire; + freshness: 'current' | 'subject_changed' | 'unknown'; + author: KnowledgeAuthorWire; + source: 'tag_mode' | 'hub' | 'mcp'; + updatedAt: string; +} + +export interface KnowledgeThreadWire extends KnowledgeEntryBaseWire { + kind: 'thread'; + anchorType: StoredAnchorType; + status: ThreadStatus; + createdAt: string; + messageCount: number; + messages?: KnowledgeMessageWire[]; + hasMoreMessages?: boolean; +} + +export interface KnowledgeDescriptionWire extends KnowledgeEntryBaseWire { + kind: 'description'; + anchorType: 'tag' | 'page'; + body: string; +} + +export type KnowledgeEntryWire = KnowledgeThreadWire | KnowledgeDescriptionWire; + +export interface ListKnowledgeWire { + entries: KnowledgeEntryWire[]; + hasMoreEntries: boolean; +} + +export interface PlanSizeWire { + width: number; + height: number; +} +export interface PlanRectWire { + x: number; + y: number; + w: number; + h: number; +} + +export interface FramePlacementWire { + id: string; + rect: PlanRectWire; + selector?: string; + anchor?: Record; +} + +export type FrameSourceWire = + | { kind: 'page'; key: string; url: string } + | { kind: 'figma'; fileKey: string; nodeId: string } + | { kind: 'image' }; + +export interface FrameScreenshotWire { + assetId: string; + capturedAt: string; + size: PlanSizeWire; + dpr: number; + capturedRect: PlanRectWire; +} + +export interface FrameLeanWire { + id: string; + projectId: string; + name: string; + parentId: string | null; + placements: FramePlacementWire[]; + size: PlanSizeWire; + extends: string | null; + source: FrameSourceWire | null; + origin: 'drawn' | 'imported' | 'observed'; + flowId: string | null; + screenshot: FrameScreenshotWire | null; + version: number; + createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; + deletedAt: string | null; +} + +export interface FrameWire extends FrameLeanWire { + marks: Record; +} + +export interface FrameListWire { + frames: FrameWire[]; +} +export interface FrameLeanListWire { + frames: FrameLeanWire[]; +} + /** * Transport-agnostic client for network-reach MCP tools. The stdio build * plugs in HttpToolClient (talks to the walkerOS app over HTTPS via the @@ -238,15 +489,107 @@ export interface ToolClient { getObserveSession?(options: ObserveSessionRef): Promise; endObserveSession?(options: ObserveSessionRef): Promise; + // Hub: the release spine, threads and knowledge (server-owned; the diff is + // never computed client-side). REQUIRED, so both doors answer the same by + // construction rather than by convention. + listReleases(options: { + projectId: string; + flowId: string; + limit?: number; + offset?: number; + }): Promise; + getRelease(options: { + projectId: string; + flowId: string; + ref: ReleaseRef; + }): Promise; + listStepHistory(options: { + projectId: string; + flowId: string; + step: string; + flow?: string; + limit?: number; + }): Promise; + setReleaseRationale(options: { + projectId: string; + flowId: string; + versionId: string; + text: string; + }): Promise; + listThreads(options: { + projectId: string; + flowId: string; + anchorType?: ThreadAnchorType; + anchorKey?: string; + status?: ThreadStatus; + includeMessages: boolean; + limit?: number; + }): Promise; + createThread(options: { + projectId: string; + flowId: string; + anchorType: ThreadAnchorType; + anchorKey: string; + anchorLabel?: string; + text: string; + }): Promise; + addThreadMessage(options: { + projectId: string; + flowId: string; + threadId: string; + text: string; + }): Promise; + listKnowledge(options: { + projectId: string; + pageKey?: string; + frameId?: string; + markId?: string; + includeMessages: boolean; + limit?: number; + }): Promise; + + // Frames: read-only. + listFrames(options: { projectId: string }): Promise; + listPageFrames(options: { + projectId: string; + pageKey: string; + }): Promise; + getFrame(options: { projectId: string; frameId: string }): Promise; + // Auth - requestDeviceCode(): Promise; + requestDeviceCode(): Promise; + /** + * Finish an authorization already under way. Returns a status only: the + * session it establishes is stored by the implementation, so a tool never + * holds token material. + */ pollForToken( deviceCode: string, options?: { timeoutMs?: number }, - ): Promise; + ): Promise; whoami(): Promise; - resolveToken(): { token: string; source: 'env' | 'config' } | null; - deleteConfig(): boolean; + /** Where a credential would come from, without resolving or refreshing it. */ + credentialSource(): 'env' | 'config' | null; + /** + * Retire the session. Where the credential was issued to this process, that + * means revoking it with the server before dropping it locally; a plane + * holding a bearer it did not issue reports nothing deleted. + */ + logout(): Promise<{ deleted: boolean }>; + + /** + * The base URL of the walkerOS app this door talks to, without a trailing + * slash. REQUIRED: a tool that puts a link in front of a person has to name + * the backend it actually reached, and a door that cannot name itself would + * emit links into a chat transcript that point somewhere else. Making it + * required means the compiler, not a runtime surprise, catches that. + * + * Each door answers from its own world: the CLI-backed client resolves the + * user's machine (`WALKEROS_APP_URL`, then the CLI config file, then the + * built-in default), while an in-process host returns the URL it is served + * on. Never derive this from the local CLI inside a tool. + */ + appBaseUrl(): string; // Diagnostics: unauthenticated reachability probe of the app's public // `/api/health` route. Resolves `{ reachable: false }` only on a real diff --git a/packages/mcps/mcp/src/tool-definitions.ts b/packages/mcps/mcp/src/tool-definitions.ts index 70b5340cd..c08fb5e59 100644 --- a/packages/mcps/mcp/src/tool-definitions.ts +++ b/packages/mcps/mcp/src/tool-definitions.ts @@ -1,6 +1,14 @@ import { z } from 'zod'; import type { ZodRawShape } from 'zod'; import { schemas } from '@walkeros/cli/dev'; +import { + HUB_MANAGE_DESCRIPTION, + HUB_MANAGE_INPUT_SCHEMA, +} from './tools/hub-manage.js'; +import { + FRAME_MANAGE_DESCRIPTION, + FRAME_MANAGE_INPUT_SCHEMA, +} from './tools/frame-manage.js'; export interface ToolAnnotations { readOnlyHint: boolean; @@ -388,4 +396,32 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ openWorldHint: true, }, }, + { + name: 'hub_manage', + title: 'Release History and Rationale', + // Description and schema are imported rather than retyped, so the two + // copies of this tool's surface are one object and cannot drift apart. + description: HUB_MANAGE_DESCRIPTION, + inputSchema: HUB_MANAGE_INPUT_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + { + name: 'frame_manage', + title: 'Frames', + // Description and schema are imported rather than retyped, so the two + // copies of this tool's surface are one object and cannot drift apart. + description: FRAME_MANAGE_DESCRIPTION, + inputSchema: FRAME_MANAGE_INPUT_SCHEMA, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, ] as const; diff --git a/packages/mcps/mcp/src/tools/auth.ts b/packages/mcps/mcp/src/tools/auth.ts index 51c1afb99..c190ca468 100644 --- a/packages/mcps/mcp/src/tools/auth.ts +++ b/packages/mcps/mcp/src/tools/auth.ts @@ -7,7 +7,7 @@ import type { ToolSpec } from '../tool-spec.js'; const TITLE = 'Authentication'; const DESCRIPTION = - 'Manage walkerOS authentication. Check login status, log in via device code flow, or log out. ' + + 'Manage walkerOS authentication. Check login status, log in via the device authorization grant, or log out. ' + 'No terminal or browser required, the MCP client handles the authorization URL.'; const inputSchema = { @@ -48,8 +48,7 @@ async function authHandlerBody(client: ToolClient, input: unknown) { try { switch (action) { case 'status': { - const resolved = client.resolveToken(); - if (!resolved) { + if (!client.credentialSource()) { return mcpResult( { authenticated: false }, { next: ['Use auth with action "login" to authenticate'] }, @@ -64,37 +63,51 @@ async function authHandlerBody(client: ToolClient, input: unknown) { case 'login': { if (deviceCode) { - const pollResult = await client.pollForToken(deviceCode, { + const poll = await client.pollForToken(deviceCode, { timeoutMs: 60000, }); - if (pollResult.success) { + if (poll.status === 'ok') { return mcpResult( - { authenticated: true, email: pollResult.email }, + { authenticated: true }, { next: [ + 'Use auth with action "status" to see which account you are on', 'Use project_manage with action "list" to see your projects', ], }, ); } - if (pollResult.status === 'pending') { + // The approval is still outstanding, so the code is still good and + // the same one comes back for the next attempt. `slow_down` is the + // server asking for a wider gap before that attempt. + if (poll.status === 'pending' || poll.status === 'slow_down') { return mcpResult({ authenticated: false, status: 'pending', - message: 'Still waiting for authorization. Try again shortly.', + message: + poll.status === 'slow_down' + ? 'Still waiting, and the server asked for a longer gap between checks. Try again in a minute.' + : 'Still waiting for authorization. Try again shortly.', deviceCode, }); } - return mcpError( - new Error(pollResult.error || 'Authorization failed'), - ); + if (poll.status === 'denied') + return mcpError(new Error('Authorization was denied.')); + if (poll.status === 'expired') + return mcpError( + new Error( + 'The one-time code expired. Run auth with action "login" for a new one.', + ), + ); + + return mcpError(new Error(poll.error)); } const code = await client.requestDeviceCode(); - const loginUrl = code.verificationUriComplete || code.verificationUri; + const loginUrl = code.verificationUriComplete; return mcpResult({ authenticated: false, @@ -106,7 +119,7 @@ async function authHandlerBody(client: ToolClient, input: unknown) { } case 'logout': { - const deleted = client.deleteConfig(); + const { deleted } = await client.logout(); const hadEnvToken = typeof process.env.WALKEROS_TOKEN === 'string' && process.env.WALKEROS_TOKEN.length > 0; diff --git a/packages/mcps/mcp/src/tools/deploy-manage.ts b/packages/mcps/mcp/src/tools/deploy-manage.ts index 3303f6bf6..57245a6e5 100644 --- a/packages/mcps/mcp/src/tools/deploy-manage.ts +++ b/packages/mcps/mcp/src/tools/deploy-manage.ts @@ -3,6 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpResult, mcpError } from '@walkeros/core'; import { isAuthError, AUTH_HINT } from '../types.js'; import { redactDisplayNames } from '../user-data.js'; +import { links } from '../links.js'; import type { ToolClient } from '../tool-client.js'; import type { ToolSpec } from '../tool-spec.js'; @@ -11,6 +12,7 @@ import { type DeploymentSummaryForResolver, type ListDeploymentsForResolver, } from './_resolvers.js'; +import { resolveDefaultProject } from './project-context.js'; import { validateActionInput, assertParam, @@ -81,6 +83,77 @@ const annotations = { openWorldHint: true, } as const; +/** + * The deployment's ID, however the response spells it: the deploy start body + * carries `deploymentId`, the detail read carries `id`. + * + * The `slug` both bodies also carry is deliberately NOT an address here. The + * detail route resolves a slug, but the page's live-status stream matches on + * the id alone, so a slug link opens a page whose status stream fails on a + * deployment that is still deploying. A response carrying neither id spelling + * gets no link. + */ +function deploymentAddress(data: unknown): string | undefined { + if (data === null || typeof data !== 'object' || Array.isArray(data)) + return undefined; + const record: Record = { ...data }; + for (const key of ['id', 'deploymentId']) { + const value = record[key]; + if (typeof value === 'string' && value !== '') return value; + } + return undefined; +} + +/** + * The deployment's page in the app. + * + * The project is resolved the way every other project-bound tool resolves it, + * falling back to the door's default. That fallback is for the LINK only: + * nothing here changes which project the call itself reads, and a door with no + * default simply yields no link rather than a wrong one. + */ +function deploymentUrl( + client: ToolClient, + projectId: string | undefined, + data: unknown, +): string | undefined { + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (resolvedProjectId === undefined) return undefined; + const deploymentId = deploymentAddress(data); + if (deploymentId === undefined) return undefined; + return links.deployment({ + baseUrl: client.appBaseUrl(), + projectId: resolvedProjectId, + deploymentId, + }); +} + +/** + * Attach the link to a response body without disturbing what it already + * carries. + * + * The key is `appUrl`, and it may never be `url`. A deployment response + * ALREADY carries `url`, and it means where this deployment is SERVING: the + * app sets it to the deployment's target while published or active. Writing + * the app page over it would silently replace a live endpoint with a UI link, + * with nothing to signal that the meaning of the field had changed. `appUrl` + * also names the `appBaseUrl()` seam it is built from. + * + * A non-object body (nothing the app returns today, but the client types are + * `unknown`) is passed through untouched rather than wrapped. + */ +function withAppUrl(data: unknown, appUrl: string | undefined): unknown { + if ( + appUrl === undefined || + data === null || + typeof data !== 'object' || + Array.isArray(data) + ) { + return data; + } + return { ...data, appUrl }; +} + function listForResolver( client: ToolClient, projectId: string | undefined, @@ -140,12 +213,24 @@ async function deployManageHandlerBody(client: ToolClient, input: unknown) { switch (action) { case 'deploy': { assertParam(flowId, 'flowId', 'deploy'); + // `projectId` travels with the deploy itself, not just with the link. + // Without it the deploy resolved the door's default while the link + // resolved the explicit id, so an explicit projectId that differed + // from the default deployed in one project and linked into another, + // and the project-scoped deployment route answered that link with a + // 404. const result = await client.deploy({ flowId, + projectId, wait: wait ?? true, flowName, }); - return mcpResult(redactDisplayNames(result), { + // The deployment's own page, whether this call waited for a terminal + // status or returned the id straight away. It is where the status the + // hint tells the agent to re-read is shown, so the person can watch it + // instead of asking again. + const appUrl = deploymentUrl(client, projectId, result); + return mcpResult(withAppUrl(redactDisplayNames(result), appUrl), { next: [ 'Use deploy_manage with action "get" to check deployment status', ], @@ -176,7 +261,8 @@ async function deployManageHandlerBody(client: ToolClient, input: unknown) { slug: resolvedSlug, projectId, }); - return mcpResult(redactDisplayNames(data)); + const appUrl = deploymentUrl(client, projectId, data); + return mcpResult(withAppUrl(redactDisplayNames(data), appUrl)); } case 'delete': { diff --git a/packages/mcps/mcp/src/tools/diagnostics.ts b/packages/mcps/mcp/src/tools/diagnostics.ts index 50353fe02..37aba1f81 100644 --- a/packages/mcps/mcp/src/tools/diagnostics.ts +++ b/packages/mcps/mcp/src/tools/diagnostics.ts @@ -1,16 +1,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpResult } from '@walkeros/core'; -import { - VERSION as CLI_VERSION, - resolveAppUrl, - compareContract, -} from '@walkeros/cli'; +import { VERSION as CLI_VERSION, compareContract } from '@walkeros/cli'; import type { ContractComparison } from '@walkeros/cli'; import openapiSpec from '@walkeros/cli/openapi/spec.json'; import type { ToolClient } from '../tool-client.js'; import type { ToolSpec } from '../tool-spec.js'; import { getPackageBaseUrl, getLastCatalogSource } from '../catalog.js'; +import { normalizeBaseUrl } from '../base-url.js'; // The bundled OpenAPI contract version, embedded at build time via the import // above (no runtime module resolution). This is the client's bundled baseline, @@ -53,12 +50,26 @@ async function diagnosticsHandlerBody( client: ToolClient, packageVersion: string, ) { + // The backend comes from the CLIENT, never from the local CLI: the local + // door resolves the user's machine, the hosted door is served by the app it + // reports, and a tool that resolved this itself would name the wrong backend + // on one of them. + const resolved = client.appBaseUrl(); + // Provenance via the same helper the catalog uses; do not add a parallel - // process.env check. - const appUrlSource: 'env' | 'default' = getPackageBaseUrl() - ? 'env' - : 'default'; - const resolved = resolveAppUrl(); + // process.env check. It is a claim about the OVERRIDE only, and a proven + // one: `env` means this process's WALKEROS_APP_URL is the URL the client + // actually named. A client that ignores the variable (the hosted door does, + // it is served on its own URL) therefore never reports `env` merely because + // the variable happens to be set in its environment. + // Normalized on both sides of the comparison: `resolved` is a base without a + // trailing slash, and the env var is under no such obligation, so comparing + // raw would report `default` for a slashed value that did set the URL. + const envAppUrl = getPackageBaseUrl(); + const appUrlSource: 'env' | 'default' = + envAppUrl !== undefined && normalizeBaseUrl(envAppUrl) === resolved + ? 'env' + : 'default'; // checkHealth is optional on ToolClient: clients that cannot probe // reachability omit it, in which case diagnostics degrades to @@ -71,21 +82,29 @@ async function diagnosticsHandlerBody( const healthUnavailable = !client.checkHealth; // Contract drift verdict: compare the client's baked baseline against the - // live app's /api/health. Degrade to 'unknown' if the probe throws so a - // network blip never breaks diagnostics. - const contractComparison: ContractComparison = await compareContract().catch( - () => ({ - verdict: 'unknown' as const, - bakedVersion: CONTRACT_OPENAPI_VERSION, - }), - ); + // live app's /api/health. The probe gets the SAME `resolved` this response + // prints as appUrl.resolved, never its own resolution: left to itself it + // reads the local machine (WALKEROS_APP_URL, the CLI config file on disk, + // then a hardcoded production default), so a hosted door, which has no CLI + // config, would report a verdict about a backend it is not served by. Two + // answers in one response must not describe two different backends. + // `resolved` is already free of a trailing slash (ToolClient.appBaseUrl + // promises that, and both doors normalize), so no normalizeBaseUrl here. + // Degrade to 'unknown' if the probe throws so a network blip never breaks + // diagnostics. + const contractComparison: ContractComparison = await compareContract({ + baseUrl: resolved, + }).catch(() => ({ + verdict: 'unknown' as const, + bakedVersion: CONTRACT_OPENAPI_VERSION, + })); const catalogInfo = getLastCatalogSource(); const warnings: string[] = []; if (appUrlSource === 'default') { warnings.push( - 'WALKEROS_APP_URL is not set; using the default app URL. Set it to target a specific backend.', + 'WALKEROS_APP_URL did not set the app URL; appUrl.resolved is the backend the client resolved on its own. On a local MCP, set WALKEROS_APP_URL to target a specific backend.', ); } if (healthUnavailable) { diff --git a/packages/mcps/mcp/src/tools/feature-gate.ts b/packages/mcps/mcp/src/tools/feature-gate.ts new file mode 100644 index 000000000..744539f1b --- /dev/null +++ b/packages/mcps/mcp/src/tools/feature-gate.ts @@ -0,0 +1,53 @@ +import { isAuthError, AUTH_HINT } from '../types.js'; + +/** The code every door answers a plan or project gate with. */ +export const FEATURE_NOT_AVAILABLE = 'FEATURE_NOT_AVAILABLE'; + +/** The features a door can refuse a whole tool for. */ +export type GatedFeature = 'hub' | 'frames'; + +function codeOf(error: unknown): string | undefined { + if (!(error instanceof Error) || !('code' in error)) return undefined; + const code = error.code; + return typeof code === 'string' ? code : undefined; +} + +/** + * A denial is recognised by its code, never by its wording: the two doors phrase + * the refusal differently and only the code is contractual. + */ +export function isFeatureDenial(error: unknown): boolean { + return codeOf(error) === FEATURE_NOT_AVAILABLE; +} + +/** + * Names the feature so an agent can tell the person what is missing. Enabling + * it is a plan or project entitlement change made in the app, never something + * a tool can do, and the hint says so instead of inviting a retry. + */ +export function featureDenialHint(feature: GatedFeature): string { + return `The "${feature}" feature is not enabled for this project. Tell the person that "${feature}" needs a plan or project entitlement that unlocks it, which is changed in the app, not through this tool.`; +} + +/** + * One hint rule for the gated tools: a feature denial names the feature, an + * auth failure points at the auth tool, a NOT_FOUND points at discovery, and + * anything else carries no hint. + * + * Order matters, though not because of the HTTP status: `isAuthError` reads the + * error's code and the words in its message, never a status. A denial carries + * its own code, but its MESSAGE is a door's free choice, and one worded + * "Forbidden" is a message `isAuthError` answers to. Reading the specific + * reason first is what keeps such a denial from being reported as a login + * problem the person could fix by logging in again. + */ +export function errorHint( + error: unknown, + feature: GatedFeature, + notFoundHint: string, +): string | undefined { + if (isFeatureDenial(error)) return featureDenialHint(feature); + if (isAuthError(error)) return AUTH_HINT; + if (codeOf(error) === 'NOT_FOUND') return notFoundHint; + return undefined; +} diff --git a/packages/mcps/mcp/src/tools/flow-manage.ts b/packages/mcps/mcp/src/tools/flow-manage.ts index fd1fd63be..0dcd6a8ac 100644 --- a/packages/mcps/mcp/src/tools/flow-manage.ts +++ b/packages/mcps/mcp/src/tools/flow-manage.ts @@ -8,6 +8,7 @@ import { keepStructural, } from '../user-data.js'; import { flowCanvasResult } from '../ui-parts.js'; +import { links } from '../links.js'; /** Peek at a Flow.Json root (v4) and decide whether the bubble should render * as web-flavoured or server-flavoured. The platform is recorded on @@ -44,6 +45,20 @@ import { resolveDefaultProject, } from './project-context.js'; +/** + * The flow's page in the app, or nothing when the response carried no id (a + * shape this module already tolerates everywhere else it reads `id`). The base + * URL comes from the door, so one definition serves both of them. + */ +function flowPageUrl( + client: ToolClient, + projectId: string, + flowId: string | undefined, +): string | undefined { + if (flowId === undefined) return undefined; + return links.flow({ baseUrl: client.appBaseUrl(), projectId, flowId }); +} + function safeSummary(flow: T): T { return flow.name !== undefined ? { ...flow, name: wrapUserData(flow.name) } @@ -401,11 +416,13 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { config?: Record; }, ); + const appUrl = flowPageUrl(client, resolvedProjectId, safe.id); return flowCanvasResult({ flowId: safe.id, configName: safe.name ?? 'default', platform: pickPlatform(safe.config), flowConfig: safe.config ?? {}, + ...(appUrl !== undefined && { appUrl }), suggestions: [ { label: 'Validate this flow', @@ -438,11 +455,17 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { config?: Record; }, ); + const createdAppUrl = flowPageUrl( + client, + resolvedProjectId, + safeCreated.id, + ); return flowCanvasResult({ flowId: safeCreated.id, configName: safeCreated.name ?? 'default', platform: pickPlatform(safeCreated.config), flowConfig: safeCreated.config ?? {}, + ...(createdAppUrl !== undefined && { appUrl: createdAppUrl }), suggestions: [ { label: 'Validate this flow', @@ -459,9 +482,13 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { case 'update': { assertParam(flowId, 'flowId', 'update'); + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } const updated = await client.updateFlow({ flowId, - projectId, + projectId: resolvedProjectId, name, content, mergePatch: patch ?? true, @@ -494,16 +521,27 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { case 'delete': { assertParam(flowId, 'flowId', 'delete'); - const deleted = await client.deleteFlow({ flowId, projectId }); + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } + const deleted = await client.deleteFlow({ + flowId, + projectId: resolvedProjectId, + }); return mcpResult(deleted); } case 'duplicate': { assertParam(flowId, 'flowId', 'duplicate'); + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } const duplicated = await client.duplicateFlow({ flowId, name, - projectId, + projectId: resolvedProjectId, }); return mcpResult( safeDetail(duplicated as { name?: string; config?: unknown }), @@ -526,8 +564,12 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { case 'preview_get': { assertParam(flowId, 'flowId', 'preview_get'); assertParam(previewId, 'previewId', 'preview_get'); + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } const data = await client.getPreview({ - projectId, + projectId: resolvedProjectId, flowId, previewId, }); @@ -563,8 +605,12 @@ async function flowManageHandlerBody(client: ToolClient, input: unknown) { case 'preview_delete': { assertParam(flowId, 'flowId', 'preview_delete'); assertParam(previewId, 'previewId', 'preview_delete'); + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } const data = await client.deletePreview({ - projectId, + projectId: resolvedProjectId, flowId, previewId, }); diff --git a/packages/mcps/mcp/src/tools/frame-manage.ts b/packages/mcps/mcp/src/tools/frame-manage.ts new file mode 100644 index 000000000..37a3b4d2b --- /dev/null +++ b/packages/mcps/mcp/src/tools/frame-manage.ts @@ -0,0 +1,489 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { mcpResult, mcpError } from '@walkeros/core'; +import { redactNestedStrings, wrapUserData } from '../user-data.js'; +import type { ToolClient, FrameLeanWire, FrameWire } from '../tool-client.js'; +import type { ToolSpec } from '../tool-spec.js'; +import { + validateActionInput, + assertParam, + FRAME_MANAGE_REQUIREMENTS, +} from '../action-requirements.js'; +import { + NO_DEFAULT_PROJECT_ERROR, + resolveDefaultProject, +} from './project-context.js'; +import { errorHint } from './feature-gate.js'; + +/** + * `frame_manage`: the place dimension of a measurement plan over MCP. + * + * A frame is a named rectangle over a page, with marks inside it. It answers + * WHERE something is measured, which nothing else on this surface carries: + * `flow_manage` holds the config, `hub_manage` holds the history and the + * reasoning, and this holds the drawing those two are argued about on. + * + * Read-only, on purpose. A frame is drawn against a live page or an import, + * and its geometry only means anything next to the pixels it was drawn on. + * A tool that cannot see the page cannot place a rectangle on it, so writing + * one from here would be guessing. Editing happens in Tag Mode or the app. + * + * Reads are progressive, the same ladder `hub_manage` uses. `list` is the lean + * project index and never carries marks, because the marks of a whole project + * are the largest thing this tool could return and are almost never what a + * caller wanted. `page` opens one page's frames with their marks, and `get` + * opens exactly one frame. + * + * It composes with `hub_manage` through ids: a mark id read here is the + * `markId` that tool's "knowledge" action takes, which is why mark ids stay + * literal while everything else inside a mark is wrapped as user data. + */ + +// Caps and patterns the server enforces. Duplicated here so the schema can +// describe them; the app's src/lib/api/schemas/frames.ts is the authority. +const FRAME_ID_PATTERN = /^frm_[A-Za-z0-9_-]{21}$/; +const MAX_PAGE_KEY_CHARS = 1024; + +const TITLE = 'Frames'; + +/** + * Exported so a host asserts parity against this exact string instead of + * retyping it. + */ +export const FRAME_MANAGE_DESCRIPTION = + 'Read the frames of a measurement plan: named rectangles with marks inside them, drawn in Tag Mode or in the app. ' + + 'Actions: list (every frame of the project, without marks), page (the frames of one page at any depth, with marks), get (one frame with its marks). ' + + 'Read-only: frames are drawn and edited in Tag Mode or the app, never here. ' + + 'A frame name is documentation; the marks inside it carry the meaning. A frame that extends another stores only what it adds. ' + + 'Use hub_manage action "knowledge" with a frameId or markId to read what people wrote on a frame. ' + + 'A markId is an id read from the marks of a frame here: mark ids come back literal so they can be passed straight back, while the text around them is wrapped as data. ' + + 'An entity action is an object carrying its id beside the raw attribute text, because the id is the address and the raw text is not.'; + +/** + * Exported so the declarative registry holds the same object rather than a + * second copy of it. + */ +export const FRAME_MANAGE_INPUT_SCHEMA = { + action: z + .enum(['list', 'page', 'get']) + .describe( + 'list the project’s frames, read one page with marks, or read one frame', + ), + projectId: z + .string() + .optional() + .describe( + 'Project ID. Optional: falls back to the default project when omitted.', + ), + pageKey: z + .string() + .min(1) + .max(MAX_PAGE_KEY_CHARS) + .optional() + .describe( + 'The page as its frames address it (the `source.key` of a page frame, usually the page URL without query). Required for page.', + ), + frameId: z + .string() + .regex(FRAME_ID_PATTERN) + .optional() + .describe( + 'Frame ID (frm_...). Required for get. Use action "list" or "page" to find one.', + ), +}; + +const annotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +} as const; + +/** + * Hints carry the verb ladder rather than the description. One constant per + * sentence the tool can emit, so the surface fixture pins wording and ordering + * by name and a reword fails a test instead of drifting. + */ +export const FRAME_HINT_OPEN_PAGE_OR_GET = + 'Use action "page" with a pageKey (a frame’s source.key) to read a page with marks, or action "get" with a frameId.'; +export const FRAME_HINT_NAMES_ARE_DOCUMENTATION = + 'Frame names are documentation; the marks inside a frame carry the meaning.'; +export const FRAME_HINT_NONE_YET = + 'This project has no frames yet. Frames are drawn in Tag Mode or the app, not through this tool.'; +export const FRAME_HINT_MARK_SPACE = + 'Marks are in their frame’s own 0..1 space; a child frame sits inside its parent through placements[].rect.'; +export const FRAME_HINT_READ_KNOWLEDGE = + 'Use hub_manage action "knowledge" with frameId (and markId) to read what people wrote here. A markId is an id from these marks, and an entity action is addressed by its own id, never by its raw text.'; +export const FRAME_HINT_NONE_ON_PAGE = + 'No frames on this page. Check the pageKey against the source.key values from action "list".'; +export const FRAME_HINT_EXTENDS_BASE = + 'This frame extends another and stores only what it adds; read the base frame (extends) for the rest.'; +export const FRAME_NOT_FOUND_HINT = + 'Use action "list" or "page" to find frame ids.'; + +/** + * The two source keys that stay literal, and only those. `kind` is the + * discriminator a reader branches on, and `key` is the pageKey the `page` + * action takes, so it has to be echoed back verbatim. Every other string in a + * source is text a reader only ever looks at: the page `url`, and the `fileKey` + * and `nodeId` of an imported design. No action takes any of them, so they are + * wrapped like any other value. + */ +const keepSourceAddress = (key: string): boolean => + key === 'kind' || key === 'key'; + +/** + * A placement's `selector` and `anchor` are opaque DOM anchors captured off a + * live page, of unbounded size: page content a reader gains nothing from and + * this tool would otherwise have to wrap. Only the rectangle is kept. + * + * `projectId` and `deletedAt` are dropped for a different reason: the caller + * named the project, and a listing only ever carries live frames, so both + * would be noise on every row. + */ +function serializeLean(frame: FrameLeanWire) { + return { + id: frame.id, + name: wrapUserData(frame.name), + parentId: frame.parentId, + extends: frame.extends, + source: redactNestedStrings(frame.source, { skip: keepSourceAddress }), + origin: frame.origin, + flowId: frame.flowId, + placements: frame.placements.map((placement) => ({ + id: placement.id, + rect: placement.rect, + })), + size: frame.size, + screenshot: frame.screenshot + ? { + assetId: frame.screenshot.assetId, + capturedAt: frame.screenshot.capturedAt, + size: frame.screenshot.size, + dpr: frame.screenshot.dpr, + capturedRect: frame.screenshot.capturedRect, + } + : null, + version: frame.version, + createdAt: frame.createdAt, + updatedAt: frame.updatedAt, + createdBy: frame.createdBy, + updatedBy: frame.updatedBy, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The address of one action inside an entity, composed exactly as the app + * composes it in `components/src/tag-plan/model.ts` (`actionChipId`). Byte + * exactness is the whole contract: the id embeds the raw text verbatim, and + * neutralising anything in it would name a mark that was never stored. + */ +function actionMarkId(entityId: string, raw: string): string { + return `${entityId}#action.${raw}`; +} + +/** + * The rule for everything inside a mark, in one place. + * + * - An IDENTIFIER stays literal wherever it appears, including as an element + * of an array, because it is an address another tool consumes and has to be + * passed back exactly as it came. + * - User-authored PROSE is always wrapped, so it cannot close the envelope and + * be read as instructions. + * - Where an identifier EMBEDS user text, the identifier is still literal. A + * sanitised id names a mark the app never stored, so cleaning it would trade + * a working address for a silent lookup failure. + * + * Marks are a passthrough record, so this cannot lean on a shared list of + * "structural" key names: a client writes whatever keys it likes, and a name + * that means "identifier" in a flow config means nothing here. Only the names + * below are addresses in a mark, and every one of them is consumed by + * `hub_manage`. + */ + +/** Keys whose value is a single address. */ +const MARK_ADDRESS_KEYS = new Set([ + // The mark id itself, and a component of the data and action addresses. + 'id', + // The first half of an ambient address, `ambient..`, and the + // discriminator a reader branches on. + 'kind', + // An entity's pointer at another entity, which is that entity's mark id. + 'link', + // The thread a note became, which action "note_add" takes as threadId. + 'threadRef', +]); + +/** Keys whose value is an ARRAY of addresses. A skip predicate cannot express + * this: array elements have no key of their own to exempt them by. */ +const MARK_ADDRESS_LIST_KEYS = new Set([ + // The entities a context band covers, by their mark ids. + 'covers', +]); + +/** + * Subtrees where NOTHING is an address, whatever it is called. + * + * A name in the sets above means "address" at the level a mark lives at, and + * something else further down. `id` is the clearest case: on a mark it is the + * mark id, but inside an anchor it is `el.id` read straight off the host page, + * which no tool takes back and the app uses only as a display label. Matching + * on the bare name would hand that page string back unwrapped while its own + * `testid` and `name` siblings wrapped, which is one small object with two + * different treatments. + * + * So the exemption is anchored to WHERE mark ids live rather than to the + * spelling of a key. An anchor is page data end to end, the same class of thing + * this tool already drops from a placement, and nothing an anchor holds is + * consumed as an address. Everything under one is text. + * + * Both keys carry the same `Anchor` shape: a mark's own anchor, and the + * per-action anchors keyed by raw action text. + */ +const MARK_PAGE_DATA_KEYS = new Set(['anchor', 'actionAnchors']); + +/** + * Whether a record's KEYS are prose rather than addresses. + * + * Object keys are never wrapped, which is deliberate and load-bearing: it is + * what keeps a `notes` key usable, since those keys ARE composed mark ids. The + * same mechanism hands back any key that is page text instead. So a record is + * one of two things, and only prose-keyed ones are reshaped into pairs. + * + * `data` is the case where one name is both. On an ambient node the property + * name is the second half of `ambient..`, so it is an address and + * the record keeps its shape. On a context there is no `kind` to compose with + * and no id built from its property names, so the same name is prose. The test + * is therefore for the `kind` that does the composing, not for the shape of the + * parent, which is what makes it a statement about meaning rather than a guess. + */ +function isProseKeyedRecord( + key: string, + owner: Record, +): boolean { + return key === 'data' && typeof owner.kind !== 'string'; +} + +/** + * Walks a mark document applying the rule above. + * + * Actions are the one case that needs more than an exemption. They are stored + * as bare page attribute text, so the text must stay wrapped, yet the address + * derived from it has to be readable. The derived id is therefore emitted + * BESIDE the still-wrapped text rather than in place of it, the same shape a + * `notes` key already has. The pairing is keyed off shape rather than position, + * so an entity nested under another is covered without this file knowing the + * plan's layout, and `actions` is the only stored field of that name. + */ +function walkMarks(value: unknown, inPageData = false): unknown { + if (typeof value === 'string') return wrapUserData(value); + if (Array.isArray(value)) { + return value.map((item) => walkMarks(item, inPageData)); + } + if (!isRecord(value)) return value; + + const entityId = typeof value.id === 'string' ? value.id : undefined; + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (MARK_PAGE_DATA_KEYS.has(key)) { + out[key] = walkMarks(child, true); + } else if ( + !inPageData && + isRecord(child) && + isProseKeyedRecord(key, value) + ) { + out[key] = proseKeyedPairs(child); + } else { + out[key] = walkMarkEntry(key, child, entityId, inPageData); + } + } + return out; +} + +/** A prose-keyed record as pairs, so the key is wrapped like the value it + * labels instead of riding out as a literal object key. */ +function proseKeyedPairs(record: Record) { + return Object.entries(record).map(([key, value]) => ({ + key: wrapUserData(key), + value: walkMarks(value), + })); +} + +function walkMarkEntry( + key: string, + child: unknown, + entityId: string | undefined, + inPageData: boolean, +): unknown { + // Inside a page-data subtree no name is an address, so every rule below is + // skipped and the value is text like anything else. + if (inPageData) return walkMarks(child, true); + + if (MARK_ADDRESS_KEYS.has(key) && typeof child === 'string') return child; + + if (MARK_ADDRESS_LIST_KEYS.has(key) && Array.isArray(child)) { + // A non-string element is not an address, so it is walked like anything + // else rather than passed through unseen. + return child.map((item) => + typeof item === 'string' ? item : walkMarks(item), + ); + } + + if (key === 'actions' && entityId !== undefined && Array.isArray(child)) { + return child.map((raw) => + typeof raw === 'string' + ? { id: actionMarkId(entityId, raw), raw: wrapUserData(raw) } + : walkMarks(raw), + ); + } + + return walkMarks(child); +} + +function serializeFrame(frame: FrameWire) { + return { + ...serializeLean(frame), + marks: walkMarks(frame.marks), + }; +} + +/** + * The same shape the transport validates against, applied again here, on the + * rule `hub_manage` states: this module's spec is deliberately drivable without + * a transport, so the caps and the id pattern have to hold inside this file + * rather than in whichever SDK copy a host linked. + */ +const frameInputSchema = z.object(FRAME_MANAGE_INPUT_SCHEMA); + +/** + * Annotated so the `action` switch below is checked for exhaustiveness: a + * member added to the enum without a case makes the function fall off its end, + * which a declared non-undefined return type refuses. + */ +type FrameManageResult = + | ReturnType + | ReturnType; + +async function frameManageHandler( + client: ToolClient, + rawInput: unknown, +): Promise { + // Parse, never assert: a raw ZodError stringifies its whole issue list into + // `message`, so the issues are rewritten as one readable line. + const parsed = frameInputSchema.safeParse(rawInput ?? {}); + if (!parsed.success) { + return mcpError( + new Error( + parsed.error.issues + .map( + (issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`, + ) + .join('; '), + ), + ); + } + const { action, projectId, pageKey, frameId } = parsed.data; + const validationError = validateActionInput( + 'frame_manage', + action, + { pageKey, frameId }, + FRAME_MANAGE_REQUIREMENTS, + ); + if (validationError) return mcpError(new Error(validationError)); + + try { + const resolvedProjectId = resolveDefaultProject(client, projectId); + if (!resolvedProjectId) { + return mcpError(new Error(NO_DEFAULT_PROJECT_ERROR)); + } + + switch (action) { + case 'list': { + const { frames } = await client.listFrames({ + projectId: resolvedProjectId, + }); + return mcpResult( + { frames: frames.map(serializeLean) }, + { + next: + frames.length === 0 + ? [FRAME_HINT_NONE_YET] + : [ + FRAME_HINT_OPEN_PAGE_OR_GET, + FRAME_HINT_NAMES_ARE_DOCUMENTATION, + ], + }, + ); + } + case 'page': { + assertParam(pageKey, 'pageKey', 'page'); + const { frames } = await client.listPageFrames({ + projectId: resolvedProjectId, + pageKey, + }); + return mcpResult( + { pageKey, frames: frames.map(serializeFrame) }, + { + next: + frames.length === 0 + ? [FRAME_HINT_NONE_ON_PAGE] + : [FRAME_HINT_MARK_SPACE, FRAME_HINT_READ_KNOWLEDGE], + }, + ); + } + case 'get': { + assertParam(frameId, 'frameId', 'get'); + const frame = await client.getFrame({ + projectId: resolvedProjectId, + frameId, + }); + return mcpResult( + { frame: serializeFrame(frame) }, + { + next: + frame.extends !== null + ? [FRAME_HINT_EXTENDS_BASE, FRAME_HINT_READ_KNOWLEDGE] + : [FRAME_HINT_READ_KNOWLEDGE], + }, + ); + } + } + } catch (error) { + // `mcpError` lifts a `code` property off the error, so whatever the client + // raises surfaces its code without a branch here. + return mcpError(error, errorHint(error, 'frames', FRAME_NOT_FOUND_HINT)); + } +} + +/** + * The tool as data, so it can be driven without an MCP transport (tests, and + * any in-process bridge). + */ +export function createFrameManageToolSpec(client: ToolClient): ToolSpec { + return { + name: 'frame_manage', + title: TITLE, + description: FRAME_MANAGE_DESCRIPTION, + inputSchema: FRAME_MANAGE_INPUT_SCHEMA, + annotations, + handler: (input) => frameManageHandler(client, input), + }; +} + +export function registerFrameManageTool(server: McpServer, client: ToolClient) { + const spec = createFrameManageToolSpec(client); + server.registerTool( + spec.name, + { + title: spec.title, + description: spec.description, + inputSchema: spec.inputSchema, + annotations: spec.annotations, + }, + (args) => frameManageHandler(client, args), + ); +} diff --git a/packages/mcps/mcp/src/tools/hub-manage.ts b/packages/mcps/mcp/src/tools/hub-manage.ts new file mode 100644 index 000000000..b182fd2c3 --- /dev/null +++ b/packages/mcps/mcp/src/tools/hub-manage.ts @@ -0,0 +1,1116 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { mcpResult, mcpError } from '@walkeros/core'; +import { wrapUserData } from '../user-data.js'; +import { links } from '../links.js'; +import type { + ToolClient, + ReleaseRef, + ReleaseDetailWire, + StepHistoryWire, + VersionAnnotationWire, + HubThreadWire, + KnowledgeEntryWire, + ThreadAnchorType, +} from '../tool-client.js'; +import type { ToolSpec } from '../tool-spec.js'; +import { + NO_DEFAULT_PROJECT_ERROR, + resolveDefaultProject, +} from './project-context.js'; +import { errorHint } from './feature-gate.js'; + +/** + * `hub_manage`: the time-and-why dimension of a flow over MCP. + * + * The rest of the MCP surface answers what a flow IS right now. Nothing else + * exposes the version spine, so this is the only door an agent has to the + * release history and the reasoning attached to it. It registers on the SAME + * server instance as `flow_manage`, `package_get`, and the rest, which is what + * lets an agent compose the two: current config from those tools, history and + * rationale from this one. + * + * Reads are progressive: `releases` is a lean index and never carries a + * snapshot, `release_get` opens exactly one release in full. The diff in + * `release_get` is computed by the server from the two stored snapshots, never + * supplied by the caller, because capture is optional by design and an + * unannotated release's diff is the only truth left to read. + * + * `knowledge` is the page dimension of the same story: what people wrote on the + * frames of a page in Tag Mode. It is addressed by frame and mark rather than + * by flow, because a note outlives the flow it was written against and one page + * carries notes about several. `pageKey` narrows to a whole page, resolved to + * its frames server-side. It reads only. Writing one happens where the mark is, + * and settling one is a person's call, the same rule the threads actions + * follow. + * + * Writes are additive only. `rationale_set` replaces the human note on one + * release and `note_add` appends to a discussion; this tool has no delete + * action of any kind, and no way to resolve a thread. Resolving states that a + * release settled a question, which is a person's call to make in the app. + */ + +// Caps and patterns the server enforces. Duplicated here so the schema can +// describe them; the app's src/lib/hub/threads.ts, src/lib/versions/step-history.ts, +// src/lib/hub/knowledge.ts and src/lib/api/schemas/frames.ts are the authority. +const MAX_ANNOTATION_TEXT_LENGTH = 4000; +const MAX_ANCHOR_KEY_LENGTH = 255; +const MAX_ANCHOR_LABEL_LENGTH = 255; +const MAX_STEP_HISTORY_LIMIT = 50; +const MAX_STEP_HISTORY_ENTRIES = 200; +const MAX_THREAD_LIMIT = 100; +const MAX_THREADS_WITH_MESSAGES = 20; +const MAX_SOURCE_KEY_LENGTH = 1024; +const MAX_MARK_ID_LENGTH = 200; +const FRAME_ID_PATTERN = /^frm_[A-Za-z0-9_-]{21}$/; +const THREAD_STATUSES = ['open', 'resolved'] as const; + +const TITLE = 'Release History and Rationale'; + +/** + * Exported so a host asserts parity against this exact string instead of + * retyping it. + */ +export const HUB_MANAGE_DESCRIPTION = + 'Read a flow’s release history and the reasoning behind it: what each release changed, and why. ' + + 'Actions: releases (lean index of a flow’s releases, newest first, no snapshots), ' + + 'release_get (one release in full: its rationale plus a server-computed diff against the release before it), ' + + 'step_history (which releases added, changed, or removed one step), ' + + 'rationale_set (write the human rationale for one release; additive, nothing is ever deleted), ' + + 'threads (the discussion anchored to one release, or every open discussion on the flow), ' + + 'note_add (add a message to a thread, or open a new one on a release), ' + + 'knowledge (read what people wrote on the marks of a page in Tag Mode: one page, one mark, or the whole project). ' + + 'Threads are resolved by a person in the app, never here: this tool can only add to a discussion. ' + + 'Knowledge is read-only here for the same reason, and is addressed by frame and mark rather than by flow. ' + + 'Steps are addressed as "type.name", the same form flow_simulate takes, for example "destination.ga4", ' + + '"transformer.router", "source.browser", "store.session", or "contract.checkout" for a contract entry. ' + + 'This tool carries history only: use flow_manage for the current config and package_get for step schemas.'; + +const ACTIONS = [ + 'releases', + 'release_get', + 'step_history', + 'rationale_set', + 'threads', + 'note_add', + 'knowledge', +] as const; + +/** + * The anchors a caller may name here: the stored vocabulary MINUS `page`. + * + * A `page` anchor hangs on a FRAME, and every action here that opens a thread + * opens it on a flow. The flow's threads response is parsed on the way out, so + * a single page-anchored flow thread written from here would make the read fail + * its own parse from then on, permanently, for everyone. Fencing the write is + * what keeps that row from ever existing. Notes on a frame are written where + * the frame is, never here. + * + * `satisfies` is the fence: the list is proved to be values the response + * schemas can carry, so a member that only the storage layer knows about + * cannot be added here without the wire contract moving first. + */ +const TOOL_ANCHOR_TYPES = [ + 'step', + 'entity_action', + 'release', + 'contract', + 'tag', +] as const satisfies readonly ThreadAnchorType[]; + +/** + * One flat schema with an `action` enum and per-action optional fields, the + * shape `flow_manage`, `project_manage`, and `secret_manage` already use. + * Per-action requirements are enforced in the handler, so later actions add a + * field and an enum member without reshaping what callers already send. + * + * Exported so the declarative registry holds the same object rather than a + * second copy of it. + */ +export const HUB_MANAGE_INPUT_SCHEMA = { + action: z + .enum(ACTIONS, { + error: () => `Unknown action. Use one of: ${ACTIONS.join(', ')}`, + }) + .describe('Which part of the release history to read or write'), + projectId: z + .string() + .optional() + .describe( + 'Project ID. Optional: falls back to the default project when omitted.', + ), + flowId: z + .string() + .optional() + .describe( + 'Flow ID (flow_...). Required for every action except "knowledge", which hangs on a page rather than a flow and refuses this field.', + ), + versionId: z + .string() + .min(1) + .optional() + .describe( + 'Release version ID (ver_...) from action "releases". Addresses one release for release_get and rationale_set. Pass this or versionNumber.', + ), + versionNumber: z + .number() + .int() + .positive() + .optional() + .describe( + 'Spine release number for this flow, the `versionNumber` field of action "releases". Alternative to versionId. Not the same as a row\'s deploymentAttempt.', + ), + step: z + .string() + .optional() + .describe( + 'Step key as "type.name", e.g. "destination.ga4" or "contract.checkout". Required for step_history.', + ), + flow: z + .string() + .optional() + .describe( + 'Named flow inside the config, e.g. "web" or "server". Optional for step_history: omit to scan every named flow. Ignored for contract steps, which are top-level.', + ), + text: z + .string() + // Trimmed BEFORE the checks. Whitespace-only text otherwise satisfies + // min(1) and then normalizes to empty downstream, which stores a blank + // message, and on `rationale_set` reaches the annotation writer where + // empty means CLEAR: the delete this additive-only tool does not have. + .trim() + .min(1) + .max(MAX_ANNOTATION_TEXT_LENGTH) + .optional() + .describe( + `The text to write (1-${MAX_ANNOTATION_TEXT_LENGTH} chars). Required for rationale_set and note_add. As rationale it replaces the note already on the release and never touches the machine summary; as a note it is appended to a thread and nothing is ever replaced.`, + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + `Page size. Releases to list for action "releases" (max 100), or releases to SCAN for step_history (max ${MAX_STEP_HISTORY_LIMIT}). For step_history this bounds releases, not entries: a step present in several named flows yields one entry per flow per release, and the scan stops at ${MAX_STEP_HISTORY_ENTRIES} entries with entriesTruncated set. Narrow with "flow" to avoid that. For threads it bounds threads, and a read that carries messages is held to ${MAX_THREADS_WITH_MESSAGES} of them. Knowledge entries are bounded the same way.`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Releases to skip. Action "releases" only.'), + anchorType: z + .enum(TOOL_ANCHOR_TYPES) + .optional() + .describe( + 'What a thread hangs on. Defaults to "release", the only anchor the app writes today. Pair it with anchorKey; a key means a different thing under each type.', + ), + anchorKey: z + .string() + .min(1) + .max(MAX_ANCHOR_KEY_LENGTH) + .optional() + .describe( + 'What the anchor addresses within its type: a release version ID (ver_...) for "release", a "type.name" step key for "step". For a release you can pass versionId or versionNumber instead. Omit entirely on action "threads" to read every thread on the flow.', + ), + anchorLabel: z + .string() + .min(1) + .max(MAX_ANCHOR_LABEL_LENGTH) + .optional() + .describe( + 'How the anchor reads on screen, stored once when a thread is opened so a later rename leaves it readable. Derived for a release ("v14"); pass it only when opening a thread on another anchor type.', + ), + threadId: z + .string() + .min(1) + .optional() + .describe( + 'Thread ID (thr_...) from action "threads". Pass it to note_add to reply in that thread; omit it to open a new thread on the anchor.', + ), + status: z + .enum(THREAD_STATUSES) + .optional() + .describe( + 'Read only threads in this state. Action "threads" only; omit for both.', + ), + pageKey: z + .string() + .min(1) + .max(MAX_SOURCE_KEY_LENGTH) + .optional() + .describe( + 'The page a note was left on, as Tag Mode addressed it, usually the page URL. Narrows action "knowledge" to every frame that page holds, at any depth; omit it to read the whole project.', + ), + frameId: z + .string() + .regex(FRAME_ID_PATTERN) + .optional() + .describe( + 'One frame (frm_...), the named rectangle a note hangs on. Action "knowledge" only. Narrower than pageKey, since a page holds several frames.', + ), + markId: z + .string() + .min(1) + .max(MAX_MARK_ID_LENGTH) + .optional() + .describe( + 'One mark within "frameId". Action "knowledge" only, and refused without frameId, since a mark id alone addresses nothing. Naming a mark is also what attaches the message bodies.', + ), +}; + +const annotations = { + readOnlyHint: false, + destructiveHint: false, + // False because of `note_add`, which APPENDS. `rationale_set` is an upsert + // and repeating it is harmless, but this block covers the whole tool, and a + // client that retries a timed-out call on the strength of an idempotent hint + // would post the same remark twice into a human discussion. Nothing here is + // destructive either way: a duplicate message can be read past, and there is + // no delete to undo it with. + idempotentHint: false, + openWorldHint: true, +} as const; + +/** + * Hints carry the verb ladder rather than the description. One constant per + * sentence the tool can emit, so the surface fixture pins wording and ordering + * by name and a reword fails a test instead of drifting. + */ +export const HUB_HINT_RELEASE_GET = + 'Use action "release_get" with a versionId or versionNumber to read one release in full, with its diff.'; +export const HUB_HINT_ROWS_ARE_DEPLOYMENTS = + 'Rows are deployments, not releases: the same versionId on several rows is a redeploy of identical content, so count distinct versionId values, and note that total counts deployments.'; +export const HUB_HINT_STEP_HISTORY = + 'Use action "step_history" to see which releases touched one step.'; +export const HUB_HINT_MASKED_ONLY = + 'The diff is empty because the only changes are inside masked values. Say that the change is not visible here rather than that nothing changed.'; +export const HUB_HINT_TRACE_STEP = + 'Use action "step_history" to trace one step across releases.'; +export const HUB_HINT_WRITE_RATIONALE = + 'This release has no human rationale. Read the diff, then use action "rationale_set" to record why it changed.'; +export const HUB_HINT_SCAN_CAPPED = + 'The scan stopped at the entry cap, so older releases were not compared. Narrow with "flow" to see the whole history of one occurrence.'; +export const HUB_HINT_NO_MATCH = + 'No scanned release touched this step. Check the step key against knownSteps, or raise limit to scan further back.'; +export const HUB_HINT_OPEN_RELEASE = + 'Use action "release_get" on one of these versionIds to read the full diff and rationale.'; +export const HUB_HINT_RATIONALE_VISIBLE = + 'The rationale is now visible on this release in the app.'; +export const HUB_HINT_CONFIRM_INDEX = + 'Use action "releases" to confirm it appears in the index.'; +export const HUB_HINT_THREADS_PAGE_CAPPED = `More threads match than were returned (the page is capped at ${MAX_THREADS_WITH_MESSAGES} when messages are attached). Narrow with anchorType and anchorKey, or with status, rather than treating this as the complete list.`; +export const HUB_HINT_NOTHING_DISCUSSED = + 'Nothing is being discussed on this flow. Use action "note_add" with a versionId to start a thread on a release.'; +export const HUB_HINT_NO_THREAD_ON_ANCHOR = + 'No thread hangs on this anchor yet. Use action "note_add" to open one.'; +export const HUB_HINT_THREADS_INDEX = + 'This is an index: message bodies are omitted. Pass a versionId, or anchorType with anchorKey, to read one discussion in full.'; +export const HUB_HINT_MESSAGES_TRUNCATED = + 'A thread here is marked hasMoreMessages: only its newest messages were returned. Say the discussion is longer than what you read rather than summarizing it as complete.'; +export const HUB_HINT_REPLY_OR_OPEN = + 'Use action "note_add" with a threadId to reply in one of these threads, or without one to open another.'; +export const HUB_HINT_RESOLVE_IN_APP = + 'Resolving a thread into a release is done by a person in the app, not through this tool.'; +export const HUB_HINT_MESSAGE_VISIBLE = + 'The message is now visible in this thread in the app.'; +export const HUB_HINT_STAYS_RESOLVED = + 'This thread is resolved and stayed resolved: a reply never retracts the release link.'; +export const HUB_HINT_READ_BACK = + 'Use action "threads" to read the discussion back.'; +export const HUB_HINT_THREAD_OPEN = + 'The thread is now open on this anchor in the app.'; +export const HUB_HINT_KEEP_ONE_THREAD = + 'Pass its threadId back to action "note_add" to keep the conversation in one place instead of opening another thread.'; +export const HUB_HINT_KNOWLEDGE_PAGE_CAPPED = + 'More knowledge matches than was returned. Narrow with pageKey, then frameId, then markId, rather than treating this as everything that was written.'; +export const HUB_HINT_NOTHING_WRITTEN = + 'Nothing has been written here. Notes are left on the page in Tag Mode, not through this tool.'; +export const HUB_HINT_KNOWLEDGE_INDEX = + 'This is an index: message bodies are omitted. Pass markId with frameId to read one mark in full.'; +export const HUB_HINT_ENTRY_NAMES_FLOW = + 'An entry names the flow it was written against in flowId, and validity says which release was live at the time.'; +export const HUB_HINT_KNOWLEDGE_READ_ONLY = + 'This tool only reads knowledge. Answering a note, and settling it, are done by a person in the app.'; +export const HUB_HINT_READ_FRAME = + 'Use frame_manage action "get" with the frameId to read the frame and its marks.'; +export const HUB_NOT_FOUND_HINT = + 'Use action "releases" to find version ids and action "threads" to find thread ids.'; + +/** + * The same shape the transport validates against, applied again here. + * + * The MCP SDK does parse arguments before invoking a tool callback, but that + * parse happens in whichever SDK copy the host linked, and this module's spec + * is deliberately drivable without a transport at all. Parsing here is what + * keeps the 4000-char cap and the additive-only guarantee inside this file: + * `text: null` would otherwise reach the annotation writer, where null means + * CLEAR, and quietly become the delete action this tool does not have. + */ +const hubInputSchema = z.object(HUB_MANAGE_INPUT_SCHEMA); + +type HubInput = z.infer; + +/** + * Why a call was refused. Carried as `code` so an agent can branch on the + * denial it is most likely to hit instead of matching prose. A denial the + * server raises, such as FEATURE_NOT_AVAILABLE, arrives with its own code and + * never needs a member here. + */ +export type HubToolErrorCode = 'INVALID_INPUT' | 'NOT_FOUND'; + +export class HubToolError extends Error { + constructor( + public readonly code: HubToolErrorCode, + message: string, + ) { + super(message); + this.name = 'HubToolError'; + } +} + +function requireParam( + value: T | undefined, + name: string, + action: string, +): T { + if (value === undefined) { + throw new HubToolError( + 'INVALID_INPUT', + `${name} is required for action "${action}"`, + ); + } + return value; +} + +/** + * Resolve the release address a caller gave. `versionId` wins when both are + * present, so an explicit id is never silently reinterpreted as a number. + */ +function releaseRef(input: HubInput, action: string): ReleaseRef { + if (input.versionId !== undefined) return { versionId: input.versionId }; + if (input.versionNumber !== undefined) { + return { versionNumber: input.versionNumber }; + } + throw new HubToolError( + 'INVALID_INPUT', + `versionId or versionNumber is required for action "${action}". Use action "releases" to find one.`, + ); +} + +/** + * Stored annotation text is written by people and lands in third-party model + * context, so it is wrapped as data. Ids, numbers, and timestamps stay literal: + * the agent has to echo them back verbatim in the next call. + */ +function serializeAnnotation(annotation: VersionAnnotationWire) { + return { + versionId: annotation.versionId, + humanText: + annotation.humanText === null ? null : wrapUserData(annotation.humanText), + generatedSummary: + annotation.generatedSummary === null + ? null + : wrapUserData(annotation.generatedSummary), + author: annotation.author, + createdAt: annotation.createdAt, + updatedAt: annotation.updatedAt, + }; +} + +/** + * The scan result as an index. An entry's own rationale text is not surfaced + * here: `release_get` is the detail read, and repeating every note across a + * fifty-release scan would make this the opposite of an index. + */ +function serializeStepHistory(history: StepHistoryWire) { + return { + step: history.step, + flow: history.flow, + entries: history.entries.map((entry) => ({ + versionId: entry.versionId, + versionNumber: entry.versionNumber, + createdAt: entry.createdAt, + flow: entry.flow, + change: entry.change, + })), + scanned: history.scanned, + truncated: history.truncated, + entriesTruncated: history.entriesTruncated, + // Step keys and flow names are addresses the caller passes straight back, + // so they stay literal for the same reason ids do. + ...(history.knownSteps !== undefined && { knownSteps: history.knownSteps }), + }; +} + +/** + * Thread text is written by people and lands in third-party model context, so + * message bodies and the anchor label are wrapped as data. Ids, the status, and + * timestamps stay literal: the agent echoes them back in the next call. + */ +function serializeThread(thread: HubThreadWire) { + return { + threadId: thread.id, + anchorType: thread.anchorType, + anchorKey: thread.anchorKey, + anchorLabel: wrapUserData(thread.anchorLabel), + status: thread.status, + // The release that settled it, null while open AND once that release is + // gone. `status` is what separates those two, never this field. + resolvedByVersionId: thread.resolvedByVersionId, + resolvedByVersionNumber: thread.resolvedByVersionNumber, + resolvedBy: thread.resolvedBy, + createdBy: thread.createdBy, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + messageCount: thread.messageCount, + ...(thread.messages !== undefined + ? { + // Set when the thread holds more than this call carried. The tail is + // the OLD end: what is here is always the newest exchange. + hasMoreMessages: thread.hasMoreMessages === true, + messages: thread.messages.map((message) => ({ + author: message.author, + text: wrapUserData(message.text), + createdAt: message.createdAt, + })), + } + : {}), + }; +} + +/** + * Knowledge is written on a live page and lands in third-party model context, + * so the description body, the message bodies, the anchor's label, the frame's + * name and the author's display name are wrapped as data. Ids, keys, the + * derived validity and freshness, and timestamps stay literal: the agent + * addresses the next call with the first and branches on the rest. + * + * The spatial placement is dropped rather than serialized. Its `element` is an + * opaque DOM anchor captured from the page, of unbounded size and shape, and + * the fractional point beside it only means something to something that draws + * the overlay. Neither tells a reader here anything, and the first is page + * content this tool would then have to wrap. + */ +function serializeKnowledge(entry: KnowledgeEntryWire) { + const shared = { + id: entry.id, + anchorType: entry.anchorType, + anchorKey: entry.anchorKey, + anchorLabel: wrapUserData(entry.anchorLabel), + frameId: entry.frameId, + /** A person names a frame, so the name is page content like any other. */ + frameName: entry.frameName === null ? null : wrapUserData(entry.frameName), + /** The flow it was written against, or null. Never a filter. */ + flowId: entry.flowId, + subjectKey: entry.subjectKey, + /** When it was true, and whether it still is. Derived at read time. */ + validity: entry.validity, + freshness: entry.freshness, + author: { + kind: entry.author.kind, + id: entry.author.id, + label: wrapUserData(entry.author.label), + }, + source: entry.source, + updatedAt: entry.updatedAt, + }; + + if (entry.kind === 'description') { + return { ...shared, kind: entry.kind, body: wrapUserData(entry.body) }; + } + + return { + ...shared, + kind: entry.kind, + status: entry.status, + createdAt: entry.createdAt, + messageCount: entry.messageCount, + ...(entry.messages !== undefined + ? { + // Set when the thread holds more than this call carried, and only + // meaningful beside the messages it describes. + hasMoreMessages: entry.hasMoreMessages === true, + messages: entry.messages.map((message) => ({ + author: message.author, + text: wrapUserData(message.text), + createdAt: message.createdAt, + })), + } + : {}), + }; +} + +async function handleReleases( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const { releases, total } = await client.listReleases({ + projectId, + flowId, + ...(input.limit !== undefined && { limit: input.limit }), + ...(input.offset !== undefined && { offset: input.offset }), + }); + + // The screen this index is of. Structured rather than only mentioned in a + // hint, so an agent can hand it on as data instead of re-typing it out of + // prose. Named `appUrl`, the one key every tool here emits a link under, so + // it can never collide with a `url` that some response already uses for + // something of its own. + const appUrl = links.release({ + baseUrl: client.appBaseUrl(), + projectId, + flowId, + }); + + return mcpResult( + { + releases: releases.map((release) => ({ + // The spine address. `release_get` and `rationale_set` take this pair + // and nothing else. + versionId: release.flowVersionId, + versionNumber: release.flowVersionNumber, + deployment: release.deploymentSlug, + deploymentType: release.deploymentType, + // The publish attempt within that deployment lineage. Deliberately NOT + // called a version: it addresses nothing in this tool, and a row + // carrying two numbers both named "version" is a trap. + deploymentAttempt: release.versionNumber, + status: release.status, + source: release.source, + errorCode: release.errorCode, + createdAt: release.createdAt, + createdBy: release.createdBy, + rationale: release.rationale + ? { + hasHumanText: release.rationale.hasHumanText, + hasGeneratedSummary: release.rationale.hasGeneratedSummary, + firstLine: + release.rationale.firstLine === null + ? null + : wrapUserData(release.rationale.firstLine), + } + : null, + })), + total, + ...(appUrl !== undefined && { appUrl }), + }, + { + next: [ + HUB_HINT_RELEASE_GET, + HUB_HINT_ROWS_ARE_DEPLOYMENTS, + HUB_HINT_STEP_HISTORY, + ], + }, + ); +} + +async function handleReleaseGet( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const release = await client.getRelease({ + projectId, + flowId, + ref: releaseRef(input, 'release_get'), + }); + + // The rendered diff is built from MASKED content, so an empty diff does not + // mean the releases are the same: a change confined to an inline secret + // renders as nothing. `contentIdentical` is decided over unmasked content, + // so it is the only trustworthy identity signal, and the gap between the two + // is stated rather than left for the reader to infer. + const contentIdentical = + release.diff !== null && release.diff.contentIdentical; + const maskedOnly = + release.diff !== null && release.diff.text === '' && !contentIdentical; + + return mcpResult( + { + versionId: release.versionId, + versionNumber: release.versionNumber, + contentHash: release.contentHash, + createdAt: release.createdAt, + createdBy: release.createdBy, + rationale: release.rationale + ? serializeAnnotation(release.rationale) + : null, + diff: release.diff + ? { + prevVersionId: release.diff.prevVersionId, + prevVersionNumber: release.diff.prevVersionNumber, + // Config values are user-authored, and inline secrets are masked + // before the text is built. + text: + release.diff.text === '' ? null : wrapUserData(release.diff.text), + /** Compared over unmasked content, so this is the real answer. */ + contentIdentical, + note: maskedOnly + ? 'These releases differ, but only inside values that are masked in the diff, typically inline secrets. Do not report this release as unchanged.' + : null, + } + : null, + diffUnavailable: release.diff + ? null + : 'This is the flow’s oldest release, so there is nothing to diff it against.', + }, + { + next: maskedOnly + ? [HUB_HINT_MASKED_ONLY] + : release.rationale?.humanText + ? [HUB_HINT_TRACE_STEP] + : [HUB_HINT_WRITE_RATIONALE], + }, + ); +} + +async function handleStepHistory( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const step = requireParam(input.step, 'step', 'step_history'); + + const history = await client.listStepHistory({ + projectId, + flowId, + step, + ...(input.flow !== undefined && { flow: input.flow }), + ...(input.limit !== undefined && { limit: input.limit }), + }); + + const next = history.entriesTruncated + ? [HUB_HINT_SCAN_CAPPED] + : history.entries.length === 0 + ? [HUB_HINT_NO_MATCH] + : [HUB_HINT_OPEN_RELEASE]; + + // The step on screen, linked only when the scan itself says the step is + // still there. + // + // `history.flow` is the caller's own filter echoed back, unvalidated, so a + // scan that named no flow gets no link (a step address without one resolves + // to nothing in the app) and a scan that named a wrong one would otherwise + // build an address the flow page opens and then refuses. Entries come back + // newest first, so an empty scan found the step in no release at all, and a + // newest entry of `removed` means the last thing that happened to it was its + // removal. Both are exactly the cases the app answers with its "not found in + // this flow" notice, and no link beats a link to a notice. + // + // The flow names on the ENTRIES are deliberately not used as the address + // instead: they say where the step USED to live. What remains is a step + // renamed since the newest release, which no signal in hand can catch; the + // app names that on screen. + const newest = history.entries[0]; + const stepIsLive = newest !== undefined && newest.change !== 'removed'; + const appUrl = stepIsLive + ? links.step({ + baseUrl: client.appBaseUrl(), + projectId, + flowId, + step: history.step, + flow: history.flow, + }) + : undefined; + + return mcpResult( + { + ...serializeStepHistory(history), + ...(appUrl !== undefined && { appUrl }), + }, + { next }, + ); +} + +/** + * Every release address goes through the detail read, by id as much as by + * number: it is the one call that proves the release belongs to THIS flow and + * hands back the number the app shows. A sibling flow's id therefore meets + * NOT_FOUND from the client before anything is written. + */ +async function resolveRelease( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, + action: string, +): Promise { + return client.getRelease({ + projectId, + flowId, + ref: releaseRef(input, action), + }); +} + +async function handleRationaleSet( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const text = requireParam(input.text, 'text', 'rationale_set'); + const release = await resolveRelease( + client, + projectId, + flowId, + input, + 'rationale_set', + ); + + const annotation = await client.setReleaseRationale({ + projectId, + flowId, + versionId: release.versionId, + text, + }); + + return mcpResult( + { + versionId: annotation.versionId, + versionNumber: release.versionNumber, + rationale: serializeAnnotation(annotation), + }, + { next: [HUB_HINT_RATIONALE_VISIBLE, HUB_HINT_CONFIRM_INDEX] }, + ); +} + +/** One anchor a thread hangs on, resolved from whatever the caller addressed. */ +interface ResolvedAnchor { + anchorType: ThreadAnchorType; + anchorKey: string; + anchorLabel: string; +} + +/** + * The anchor the caller addressed, or null when they addressed none. + * + * A release can be addressed the way every other action here takes one + * (`versionId` / `versionNumber`), which is resolved through the flow so a + * sibling flow's release is refused and the display label is derived from the + * real release number rather than trusted from the caller. Any other anchor + * type is taken as given: this tool cannot verify a step key against a config + * it does not load, and a thread on a key that no longer exists is exactly what + * `anchorLabel` and the unanchored bucket are for. + */ +async function resolveAnchor( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, + action: string, +): Promise { + const type = input.anchorType ?? 'release'; + + if (type === 'release') { + if (input.versionId === undefined && input.versionNumber === undefined) { + if (input.anchorKey === undefined) return null; + // An explicit key for a release anchor still has to be a release of this + // flow, or the thread would point at nothing readable. + const byKey = await client.getRelease({ + projectId, + flowId, + ref: { versionId: input.anchorKey }, + }); + return { + anchorType: 'release', + anchorKey: byKey.versionId, + anchorLabel: `v${byKey.versionNumber}`, + }; + } + + const release = await resolveRelease( + client, + projectId, + flowId, + input, + action, + ); + return { + anchorType: 'release', + anchorKey: release.versionId, + anchorLabel: `v${release.versionNumber}`, + }; + } + + if (input.anchorKey === undefined) return null; + return { + anchorType: type, + anchorKey: input.anchorKey, + anchorLabel: input.anchorLabel ?? input.anchorKey, + }; +} + +async function handleThreads( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const anchor = await resolveAnchor( + client, + projectId, + flowId, + input, + 'threads', + ); + + // Message bodies come back only for ONE anchor. A whole-flow listing is an + // index, and carrying every word ever written on every anchor would make it + // the opposite of one. + const { threads, hasMoreThreads } = await client.listThreads({ + projectId, + flowId, + includeMessages: anchor !== null, + ...(anchor !== null + ? { anchorType: anchor.anchorType, anchorKey: anchor.anchorKey } + : {}), + ...(input.status !== undefined && { status: input.status }), + ...(input.limit !== undefined && { + limit: Math.min(input.limit, MAX_THREAD_LIMIT), + }), + }); + + const truncated = threads.some((thread) => thread.hasMoreMessages === true); + + // The page ceiling is not what the caller asked for: a read that carries + // message bodies is clamped hard. Saying so keeps an agent from reporting a + // truncated page as the whole of a flow's discussion. + const pageHint = hasMoreThreads ? [HUB_HINT_THREADS_PAGE_CAPPED] : []; + + const next = + threads.length === 0 + ? [ + anchor === null + ? HUB_HINT_NOTHING_DISCUSSED + : HUB_HINT_NO_THREAD_ON_ANCHOR, + ] + : anchor === null + ? [...pageHint, HUB_HINT_THREADS_INDEX] + : [ + ...pageHint, + ...(truncated ? [HUB_HINT_MESSAGES_TRUNCATED] : []), + HUB_HINT_REPLY_OR_OPEN, + HUB_HINT_RESOLVE_IN_APP, + ]; + + // Where these are read. An anchored read links the anchor's own screen, and + // only a release anchor can be addressed from the shape held here. An + // unanchored read is the flow's whole discussion, which is read in the + // release history, so it links that. + const flowTarget = { baseUrl: client.appBaseUrl(), projectId, flowId }; + const appUrl = + anchor === null + ? links.release(flowTarget) + : links.thread({ ...flowTarget, anchorType: anchor.anchorType }); + + return mcpResult( + { + threads: threads.map(serializeThread), + hasMoreThreads, + ...(appUrl !== undefined && { appUrl }), + }, + { next }, + ); +} + +async function handleNoteAdd( + client: ToolClient, + projectId: string, + flowId: string, + input: HubInput, +) { + const text = requireParam(input.text, 'text', 'note_add'); + + if (input.threadId !== undefined) { + const thread = await client.addThreadMessage({ + projectId, + flowId, + threadId: input.threadId, + text, + }); + return mcpResult( + { thread: serializeThread(thread) }, + { + next: [ + HUB_HINT_MESSAGE_VISIBLE, + thread.status === 'resolved' + ? HUB_HINT_STAYS_RESOLVED + : HUB_HINT_READ_BACK, + ], + }, + ); + } + + const anchor = await resolveAnchor( + client, + projectId, + flowId, + input, + 'note_add', + ); + if (anchor === null) { + throw new HubToolError( + 'INVALID_INPUT', + 'note_add needs somewhere to write: pass threadId to reply, or versionId (or anchorType with anchorKey) to open a thread on an anchor.', + ); + } + + const thread = await client.createThread({ + projectId, + flowId, + anchorType: anchor.anchorType, + anchorKey: anchor.anchorKey, + // A release anchor is labeled by the server from the release number, so the + // label travels only for the other anchor types. + ...(anchor.anchorType !== 'release' && { anchorLabel: anchor.anchorLabel }), + text, + }); + + return mcpResult( + { thread: serializeThread(thread) }, + { next: [HUB_HINT_THREAD_OPEN, HUB_HINT_KEEP_ONE_THREAD] }, + ); +} + +async function handleKnowledge( + client: ToolClient, + projectId: string, + input: HubInput, +) { + // Message bodies come back only for ONE mark, the rule `threads` follows for + // one anchor: a page-wide or project-wide read is an index, and carrying + // every word written on every mark would make it the opposite of one. + const oneMark = input.markId !== undefined; + + // The server refuses a mark without its page rather than answering an + // unnarrowed list, and that refusal carries its own code through mcpError. + const { entries, hasMoreEntries } = await client.listKnowledge({ + projectId, + includeMessages: oneMark, + ...(input.pageKey !== undefined && { pageKey: input.pageKey }), + ...(input.frameId !== undefined && { frameId: input.frameId }), + ...(input.markId !== undefined && { markId: input.markId }), + ...(input.limit !== undefined && { limit: input.limit }), + }); + + const truncated = entries.some( + (entry) => entry.kind === 'thread' && entry.hasMoreMessages === true, + ); + + const pageHint = hasMoreEntries ? [HUB_HINT_KNOWLEDGE_PAGE_CAPPED] : []; + + const next = + entries.length === 0 + ? [HUB_HINT_NOTHING_WRITTEN] + : [ + ...pageHint, + ...(oneMark + ? truncated + ? [HUB_HINT_MESSAGES_TRUNCATED] + : [] + : [HUB_HINT_KNOWLEDGE_INDEX]), + HUB_HINT_ENTRY_NAMES_FLOW, + HUB_HINT_KNOWLEDGE_READ_ONLY, + HUB_HINT_READ_FRAME, + ]; + + return mcpResult( + { entries: entries.map(serializeKnowledge), hasMoreEntries }, + { next }, + ); +} + +export async function hubManageHandler(client: ToolClient, rawInput: unknown) { + try { + // Parse, never assert: see the note on `hubInputSchema`. A raw ZodError + // stringifies its whole issue list into `message`, so the issues are + // rewritten as one readable line carrying the same code as every other + // refusal here. + const parsed = hubInputSchema.safeParse(rawInput ?? {}); + if (!parsed.success) { + throw new HubToolError( + 'INVALID_INPUT', + parsed.error.issues + .map( + (issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`, + ) + .join('; '), + ); + } + const input: HubInput = parsed.data; + const { action } = input; + + const projectId = resolveDefaultProject(client, input.projectId); + if (!projectId) { + throw new HubToolError('INVALID_INPUT', NO_DEFAULT_PROJECT_ERROR); + } + + if (action === 'knowledge') { + // Nothing here narrows knowledge by flow. Taking the field and answering + // with every page's notes anyway is the failure mode this refuses: the + // caller asked for one flow's and would read the answer as that. + if (input.flowId !== undefined) { + throw new HubToolError( + 'INVALID_INPUT', + 'flowId does not narrow action "knowledge", which is addressed by frame and mark: pass pageKey, frameId or markId instead. Each entry names the flow it was written against.', + ); + } + return await handleKnowledge(client, projectId, input); + } + + const flowId = requireParam(input.flowId, 'flowId', action); + + switch (action) { + case 'releases': + return await handleReleases(client, projectId, flowId, input); + case 'release_get': + return await handleReleaseGet(client, projectId, flowId, input); + case 'step_history': + return await handleStepHistory(client, projectId, flowId, input); + case 'rationale_set': + return await handleRationaleSet(client, projectId, flowId, input); + case 'threads': + return await handleThreads(client, projectId, flowId, input); + case 'note_add': + return await handleNoteAdd(client, projectId, flowId, input); + } + } catch (error) { + // `mcpError` lifts a `code` property off the error, so a HubToolError and + // whatever the client raises both surface their code without a branch here. + return mcpError(error, errorHint(error, 'hub', HUB_NOT_FOUND_HINT)); + } +} + +/** + * The tool as data, so it can be driven without an MCP transport (tests, and + * any in-process bridge). + */ +export function createHubManageToolSpec(client: ToolClient): ToolSpec { + return { + name: 'hub_manage', + title: TITLE, + description: HUB_MANAGE_DESCRIPTION, + inputSchema: HUB_MANAGE_INPUT_SCHEMA, + annotations, + handler: (input) => hubManageHandler(client, input), + }; +} + +export function registerHubManageTool(server: McpServer, client: ToolClient) { + const spec = createHubManageToolSpec(client); + server.registerTool( + spec.name, + { + title: spec.title, + description: spec.description, + inputSchema: spec.inputSchema, + annotations: spec.annotations, + }, + (args) => hubManageHandler(client, args), + ); +} diff --git a/packages/mcps/mcp/src/tools/project-context.ts b/packages/mcps/mcp/src/tools/project-context.ts index 8e4a0f4fe..45a4898ac 100644 --- a/packages/mcps/mcp/src/tools/project-context.ts +++ b/packages/mcps/mcp/src/tools/project-context.ts @@ -1,7 +1,15 @@ import type { ToolClient } from '../tool-client.js'; +/** + * Shared by every project-bound tool, so the remedy is worded once. + * + * It names what to do, not just what went wrong, and it names where to get the + * missing value. The per-call remedy leads because it always works: a selection + * made with `set_default` is held by the process serving the connection and is + * gone after a reconnect, by the ruling in the app's MCP route. + */ export const NO_DEFAULT_PROJECT_ERROR = - 'No default project set and no projectId provided. Run project_manage action "set_default", or pass projectId.'; + 'No project selected and no projectId given. Pass projectId on this call, or call project_manage action "set_default" to select one. project_manage action "list" returns the available ids.'; /** Resolves the project for actions that fall back to the CLI default when * `projectId` is omitted. Returns the resolved id, or undefined when there is diff --git a/packages/mcps/mcp/src/ui-parts.ts b/packages/mcps/mcp/src/ui-parts.ts index c383ec845..90d1731b7 100644 --- a/packages/mcps/mcp/src/ui-parts.ts +++ b/packages/mcps/mcp/src/ui-parts.ts @@ -12,6 +12,19 @@ export interface FlowCanvasPayload { flowConfig: Record; highlight?: { stepAddress: string; reason: string }; suggestions?: SuggestionTile[]; + /** + * Absolute link to this flow's page in the app, from `links.flow`. + * + * Named for the `appBaseUrl()` seam it is built from, and never `url`: that + * is a field several app responses already use for something of their own + * (a deployment's `url` is where it SERVES), and one key meaning one thing + * across every tool is what keeps a link from ever landing on top of it. + * + * Optional because a tool that cannot name the project or the flow emits no + * link rather than a guess, and because the in-app chat renders the canvas + * itself and has no use for a link to the page it is already on. + */ + appUrl?: string; } export interface FlowCanvasToolResult extends FlowCanvasPayload { diff --git a/packages/mcps/source-browser/CHANGELOG.md b/packages/mcps/source-browser/CHANGELOG.md index 801acb71d..f27b2741d 100644 --- a/packages/mcps/source-browser/CHANGELOG.md +++ b/packages/mcps/source-browser/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/mcp-source-browser +## 4.6.0 + +### Patch Changes + +- @walkeros/web-source-browser@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/mcps/source-browser/package.json b/packages/mcps/source-browser/package.json index a54fd4026..60fdb9bcb 100644 --- a/packages/mcps/source-browser/package.json +++ b/packages/mcps/source-browser/package.json @@ -1,6 +1,6 @@ { "name": "@walkeros/mcp-source-browser", - "version": "4.5.0", + "version": "4.6.0", "description": "MCP server for walkerOS data-elb HTML tagging — generate, parse, and validate tracking attributes with real DOM parsing", "license": "MIT", "type": "module", @@ -31,16 +31,16 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-source-browser": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-source-browser": "4.6.0", "jsdom": "^29.1.1" }, "devDependencies": { "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", - "@walkeros/config": "4.5.0", - "@walkeros/collector": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/config": "4.6.0", + "@walkeros/collector": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "peerDependencies": { "zod": "^4.0" diff --git a/packages/server/core/CHANGELOG.md b/packages/server/core/CHANGELOG.md index 91f55ece1..379416388 100644 --- a/packages/server/core/CHANGELOG.md +++ b/packages/server/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-core +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/core/package.json b/packages/server/core/package.json index 6fe57fb54..1596b2a80 100644 --- a/packages/server/core/package.json +++ b/packages/server/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-core", "description": "Server-specific utilities for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,8 +27,8 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -52,6 +52,6 @@ } ], "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } } diff --git a/packages/server/destinations/amplitude/CHANGELOG.md b/packages/server/destinations/amplitude/CHANGELOG.md index 06e358dae..5ca771928 100644 --- a/packages/server/destinations/amplitude/CHANGELOG.md +++ b/packages/server/destinations/amplitude/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-amplitude +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/amplitude/package.json b/packages/server/destinations/amplitude/package.json index c4b9f35c2..981f409cd 100644 --- a/packages/server/destinations/amplitude/package.json +++ b/packages/server/destinations/amplitude/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-amplitude", "description": "Amplitude server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@amplitude/analytics-node": "^1.5.53", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/api/CHANGELOG.md b/packages/server/destinations/api/CHANGELOG.md index e23f375b0..90c7a85c1 100644 --- a/packages/server/destinations/api/CHANGELOG.md +++ b/packages/server/destinations/api/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-api +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/server/destinations/api/package.json b/packages/server/destinations/api/package.json index a737f1be6..1ff4106d8 100644 --- a/packages/server/destinations/api/package.json +++ b/packages/server/destinations/api/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-api", "description": "API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/aws/CHANGELOG.md b/packages/server/destinations/aws/CHANGELOG.md index e5a6b6dc2..ed452bffe 100644 --- a/packages/server/destinations/aws/CHANGELOG.md +++ b/packages/server/destinations/aws/CHANGELOG.md @@ -1,5 +1,17 @@ # @walkeros/server-destination-aws +## 4.6.0 + +### Patch Changes + +- 403ff6c: The Firehose destination no longer discards every config field except + `settings` during init, which silently dropped `before`, `consent`, `mapping`, + `data` and `next`. A `before` transformer chain configured on the destination + never ran, and a `consent` requirement was never enforced. The SNS destination + in the same package was already correct. + - @walkeros/server-core@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/aws/package.json b/packages/server/destinations/aws/package.json index 7c61658d6..11528ce25 100644 --- a/packages/server/destinations/aws/package.json +++ b/packages/server/destinations/aws/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-aws", "description": "AWS server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -38,8 +38,8 @@ "@aws-sdk/client-firehose": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", "@aws-sdk/client-sts": "^3.952.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/aws/src/firehose/__tests__/firehose.test.ts b/packages/server/destinations/aws/src/firehose/__tests__/firehose.test.ts index b77ab9f78..7b1e3db38 100644 --- a/packages/server/destinations/aws/src/firehose/__tests__/firehose.test.ts +++ b/packages/server/destinations/aws/src/firehose/__tests__/firehose.test.ts @@ -1,6 +1,10 @@ import type { Config, Settings, Destination, Env } from '../types'; import type { Collector } from '@walkeros/core'; -import { createEvent, createMockContext, createMockLogger } from '@walkeros/core'; +import { + createEvent, + createMockContext, + createMockLogger, +} from '@walkeros/core'; import * as examples from '../examples'; const { env } = examples; @@ -80,6 +84,23 @@ describe('Firehose', () => { }); }); + test('init keeps config fields other than settings', async () => { + const config = (await destination.init({ + config: { + settings: settingsConfig, + before: 'fingerprint', + consent: { marketing: true }, + }, + collector: mockCollector, + env: testEnv, + logger: createMockLogger(), + id: 'test-firehose', + })) as Config; + + expect(config.before).toBe('fingerprint'); + expect(config.consent).toEqual({ marketing: true }); + }); + test('push', async () => { const config = await getConfig(settingsConfig); const mockCollector = {} as Collector.Instance; diff --git a/packages/server/destinations/aws/src/firehose/config.ts b/packages/server/destinations/aws/src/firehose/config.ts index df4c48451..96e9824f7 100644 --- a/packages/server/destinations/aws/src/firehose/config.ts +++ b/packages/server/destinations/aws/src/firehose/config.ts @@ -10,5 +10,5 @@ export function getConfig( if (settings.firehose) settings.firehose = getConfigFirehose(settings.firehose, env); - return { settings }; + return { ...partialConfig, settings }; } diff --git a/packages/server/destinations/bing/CHANGELOG.md b/packages/server/destinations/bing/CHANGELOG.md index 960fb902d..ccaeb537f 100644 --- a/packages/server/destinations/bing/CHANGELOG.md +++ b/packages/server/destinations/bing/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-meta +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/bing/package.json b/packages/server/destinations/bing/package.json index 485db1234..55466ab00 100644 --- a/packages/server/destinations/bing/package.json +++ b/packages/server/destinations/bing/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-bing", "description": "Microsoft Advertising (Bing UET CAPI) server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/criteo/CHANGELOG.md b/packages/server/destinations/criteo/CHANGELOG.md index 047b82782..ad3df8bd2 100644 --- a/packages/server/destinations/criteo/CHANGELOG.md +++ b/packages/server/destinations/criteo/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-criteo +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/criteo/package.json b/packages/server/destinations/criteo/package.json index f30221c78..b9612942d 100644 --- a/packages/server/destinations/criteo/package.json +++ b/packages/server/destinations/criteo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-criteo", "description": "Criteo Events API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/customerio/CHANGELOG.md b/packages/server/destinations/customerio/CHANGELOG.md index daafabc65..63d2a35d4 100644 --- a/packages/server/destinations/customerio/CHANGELOG.md +++ b/packages/server/destinations/customerio/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-customerio +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/customerio/package.json b/packages/server/destinations/customerio/package.json index f512a2426..b4e2b47fa 100644 --- a/packages/server/destinations/customerio/package.json +++ b/packages/server/destinations/customerio/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-customerio", "description": "Customer.io messaging automation server destination for walkerOS (customerio-node, Track + Transactional API)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "customerio-node": "^4.2.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/datamanager/CHANGELOG.md b/packages/server/destinations/datamanager/CHANGELOG.md index 55b8afb0c..deafec2f8 100644 --- a/packages/server/destinations/datamanager/CHANGELOG.md +++ b/packages/server/destinations/datamanager/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-datamanager +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/datamanager/package.json b/packages/server/destinations/datamanager/package.json index df958a9bb..87d6a7167 100644 --- a/packages/server/destinations/datamanager/package.json +++ b/packages/server/destinations/datamanager/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-datamanager", "description": "Google Data Manager server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "google-auth-library": "^10.5.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/file/CHANGELOG.md b/packages/server/destinations/file/CHANGELOG.md index 5a528ec38..fb1f8d7f9 100644 --- a/packages/server/destinations/file/CHANGELOG.md +++ b/packages/server/destinations/file/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-file +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/file/package.json b/packages/server/destinations/file/package.json index 4fff0cd84..beb52b081 100644 --- a/packages/server/destinations/file/package.json +++ b/packages/server/destinations/file/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-file", "description": "Local file sink for walkerOS server flows (JSONL, TSV, CSV)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/gcp/CHANGELOG.md b/packages/server/destinations/gcp/CHANGELOG.md index f314dde8e..5e27a5316 100644 --- a/packages/server/destinations/gcp/CHANGELOG.md +++ b/packages/server/destinations/gcp/CHANGELOG.md @@ -1,5 +1,18 @@ # @walkeros/server-destination-gcp +## 4.6.0 + +### Patch Changes + +- 8802281: The BigQuery destination no longer applies `config.timeout` as a + deadline on the Storage Write API append stream, which killed healthy + connections roughly every ten seconds and caused reconnect churn, latency + spikes, and intermittent 5xx responses. Error logs now show the error's + message, name and status code in CLI output, and no longer include event + payloads. + - @walkeros/server-core@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/gcp/package.json b/packages/server/destinations/gcp/package.json index e13da67cc..d1017b987 100644 --- a/packages/server/destinations/gcp/package.json +++ b/packages/server/destinations/gcp/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-gcp", "description": "Google Cloud Platform server destinations for walkerOS (BigQuery, Pub/Sub)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -33,8 +33,8 @@ "@google-cloud/bigquery": "^8.1.1", "@google-cloud/bigquery-storage": "^5.1.0", "@google-cloud/pubsub": "^5.3.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts index 93f324883..c982bce08 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/index.test.ts @@ -574,20 +574,19 @@ describe('Server Destination BigQuery', () => { }); describe('gRPC deadline (config.timeout)', () => { - // The Storage Write API appendRows runs on the long-lived bidi stream opened - // by createStreamConnection. The deadline is therefore applied at the - // stream-connection level (gax CallOptions.timeout), which governs every - // appendRows/getResult on that stream, and at the unary getWriteStream call. - // The deadline derives from the standard per-step config.timeout, the same - // value the collector uses to race the push, not a destination-custom knob. - - test('init forwards the standard config.timeout as the gax deadline on the appendRows stream and schema fetch', async () => { + // config.timeout bounds two things: the collector's per-delivery race + // (its own default, in the collector) and, here, the UNARY getWriteStream + // schema fetch. It is never applied to the appendRows bidi stream: a gax + // deadline there bounds the stream's total lifetime and would kill a + // healthy connection on expiry. The stream keeps the SDK's own default. + + test('init forwards config.timeout to the unary schema fetch but never to the appendRows stream', async () => { await callInit({ projectId, datasetId, tableId }, undefined, 5000); const streamCall = __getMockCalls().find( (c) => c.method === 'createStreamConnection', ); - expect(streamCall?.args[1]).toEqual({ timeout: 5000 }); + expect(streamCall?.args[1]).toBeUndefined(); const schemaCall = __getMockCalls().find( (c) => c.method === 'getWriteStream', @@ -595,13 +594,13 @@ describe('Server Destination BigQuery', () => { expect(schemaCall?.args[1]).toEqual({ timeout: 5000 }); }); - test('init applies the default deadline when config.timeout is unset', async () => { + test('init applies the default unary deadline when config.timeout is unset', async () => { await callInit({ projectId, datasetId, tableId }); const streamCall = __getMockCalls().find( (c) => c.method === 'createStreamConnection', ); - expect(streamCall?.args[1]).toEqual({ timeout: 10000 }); + expect(streamCall?.args[1]).toBeUndefined(); const schemaCall = __getMockCalls().find( (c) => c.method === 'getWriteStream', @@ -611,14 +610,15 @@ describe('Server Destination BigQuery', () => { test('init treats config.timeout: 0 as "use the default" (no zero-ms deadline)', async () => { // A zero-ms deadline would expire immediately; 0 is not a "disabled" - // sentinel. Resolution falls back to the default so writer.ts always gets - // a positive deadline, mirroring the collector's resolveDestinationTimeout. + // sentinel. Resolution falls back to the default so the unary call + // always gets a positive deadline, mirroring the collector's + // resolveDestinationTimeout. await callInit({ projectId, datasetId, tableId }, undefined, 0); const streamCall = __getMockCalls().find( (c) => c.method === 'createStreamConnection', ); - expect(streamCall?.args[1]).toEqual({ timeout: 10000 }); + expect(streamCall?.args[1]).toBeUndefined(); const schemaCall = __getMockCalls().find( (c) => c.method === 'getWriteStream', @@ -992,5 +992,87 @@ describe('Server Destination BigQuery', () => { expect(leaked[0]).toBe(settings.connection); expect(brokenConnection.listenerCount('error')).toBe(0); }); + + test('a re-opened appendRows stream carries no gax deadline (push path)', async () => { + const config = await callInit( + { projectId, datasetId, tableId }, + undefined, + 5000, + ); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + __getLastConnection().__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + + __resetMockCalls(); + + await destination.push( + event, + createMockContext({ + config, + rule: undefined, + data: undefined, + env: testEnv, + id: 'test-bq', + }), + ); + expect(settings.writerBroken).toBe(false); + + // The re-open runs through the reopenWriter closure built in init with + // the resolved timeout: the fresh stream must be as unbounded as the + // first one, while the unary schema fetch keeps the deadline. + const streamCalls = __getMockCalls().filter( + (c) => c.method === 'createStreamConnection', + ); + expect(streamCalls).toHaveLength(1); + expect(streamCalls[0]?.args[1]).toBeUndefined(); + + const schemaCall = __getMockCalls().find( + (c) => c.method === 'getWriteStream', + ); + expect(schemaCall?.args[1]).toEqual({ timeout: 5000 }); + }); + + test('a re-opened appendRows stream carries no gax deadline (batch path)', async () => { + if (!destination.pushBatch) throw new Error('pushBatch missing'); + + const config = await callInit( + { projectId, datasetId, tableId }, + undefined, + 5000, + ); + if (!config) throw new Error('init returned void'); + const { settings } = config; + if (!settings) throw new Error('settings missing after init'); + + __getLastConnection().__emitConnectionError(new Error('stream gone')); + expect(settings.writerBroken).toBe(true); + + __resetMockCalls(); + + const logger = createMockLogger(); + const events = [createEvent(), createEvent()]; + const data: Array = events.map(() => undefined); + const entries = events.map((e) => ({ event: e })); + + await destination.pushBatch( + { key: 'k', events, data, entries }, + createMockContext({ + config, + env: testEnv, + logger, + id: 'test-bq', + }), + ); + expect(settings.writerBroken).toBe(false); + + const streamCalls = __getMockCalls().filter( + (c) => c.method === 'createStreamConnection', + ); + expect(streamCalls).toHaveLength(1); + expect(streamCalls[0]?.args[1]).toBeUndefined(); + }); }); }); diff --git a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts index e0943ae09..097a0ae03 100644 --- a/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts +++ b/packages/server/destinations/gcp/src/bigquery/__tests__/writer.test.ts @@ -127,7 +127,7 @@ describe('openWriter', () => { }); }); - test('forwards timeout as the gax deadline to createStreamConnection and getWriteStream', async () => { + test('never passes CallOptions to createStreamConnection, even when a timeout is set', async () => { const logger = createMockLogger(); await openWriter( { projectId: 'p', datasetId: 'd', tableId: 't', timeout: 7500 }, @@ -136,20 +136,37 @@ describe('openWriter', () => { const streamCall = __getMockCalls().find( (c) => c.method === 'createStreamConnection', ); - expect(streamCall?.args[1]).toEqual({ timeout: 7500 }); + // A gax CallOptions.timeout on the appendRows bidi stream is the stream's + // TOTAL deadline: it kills the connection when it expires no matter how + // healthy the stream is. The stream must keep the SDK's own long-lived + // default; per-delivery bounds are the collector's job. + expect(streamCall).toBeDefined(); + expect(streamCall?.args[1]).toBeUndefined(); + }); + + test('forwards timeout as the gax deadline to the unary getWriteStream only', async () => { + const logger = createMockLogger(); + await openWriter( + { projectId: 'p', datasetId: 'd', tableId: 't', timeout: 7500 }, + logger, + ); const schemaCall = __getMockCalls().find( (c) => c.method === 'getWriteStream', ); expect(schemaCall?.args[1]).toEqual({ timeout: 7500 }); }); - test('omits the deadline when timeout is unset', async () => { + test('omits all deadlines when timeout is unset', async () => { const logger = createMockLogger(); await openWriter({ projectId: 'p', datasetId: 'd', tableId: 't' }, logger); const streamCall = __getMockCalls().find( (c) => c.method === 'createStreamConnection', ); expect(streamCall?.args[1]).toBeUndefined(); + const schemaCall = __getMockCalls().find( + (c) => c.method === 'getWriteStream', + ); + expect(schemaCall?.args[1]).toBeUndefined(); }); test('closeWriter calls close on writer and writeClient', async () => { diff --git a/packages/server/destinations/gcp/src/bigquery/index.ts b/packages/server/destinations/gcp/src/bigquery/index.ts index d0fa8f4b7..15eb27309 100644 --- a/packages/server/destinations/gcp/src/bigquery/index.ts +++ b/packages/server/destinations/gcp/src/bigquery/index.ts @@ -8,10 +8,19 @@ import { openWriter, closeWriter } from './writer'; // Types export * as DestinationBigQuery from './types'; -// Default gRPC deadline (ms) when the standard per-step `config.timeout` is -// unset or <= 0. Mirrors the collector's DEFAULT_DESTINATION_TIMEOUT_MS and its -// `> 0 ? value : default` rule (packages/collector/src/destination.ts), so the -// gax deadline matches the window the collector uses to race the push. +// Timeout layering (who owns which bound): +// - The COLLECTOR bounds every delivery: it races each push/pushBatch at +// config.timeout (default 10s, packages/collector/src/destination.ts) and +// converts a hung delivery into a counted DLQ failure. That is the +// per-operation protection. +// - This DESTINATION owns the connection lifecycle: broken-writer detection +// via the connection 'error' listener, one lazy re-open on the next push +// (ensureWriter), collapsed across concurrent pushes. +// - The gax deadline below bounds UNARY control-plane calls only (the +// getWriteStream schema fetch during open/re-open). It is never applied to +// the appendRows bidi stream: a deadline there bounds the stream's total +// lifetime and would kill a healthy connection on expiry. The stream keeps +// the SDK's own long-lived AppendRows default. const DEFAULT_TIMEOUT_MS = 10_000; export const destinationBigQuery: Destination = { @@ -24,9 +33,9 @@ export const destinationBigQuery: Destination = { async init({ config: partialConfig, env, logger, id, reportError }) { const config = getConfig(partialConfig, env, logger); - // The gax deadline derives from the standard per-step config.timeout (the - // same value the collector uses to race the push), not a destination-custom - // knob. A positive number wins; 0/unset falls back to the default. + // Deadline for unary control-plane calls, derived from the standard + // per-step config.timeout (the same value the collector uses to race the + // push). A positive number wins; 0/unset falls back to the default. const timeout = config.timeout && config.timeout > 0 ? config.timeout @@ -51,7 +60,8 @@ export const destinationBigQuery: Destination = { // broken writer. Closes over the openWriter args + onConnectionError so the // fresh connection carries the same containment handler. The reused args // (projectId/datasetId/tableId/bigquery/timeout) are immutable post-init, so - // a re-open targets the same table with the same auth and deadline. + // a re-open targets the same table with the same auth and unary-call + // deadline. settings.reopenWriter = () => openWriter( { diff --git a/packages/server/destinations/gcp/src/bigquery/writer.ts b/packages/server/destinations/gcp/src/bigquery/writer.ts index 8fe2868b4..a2c24b2db 100644 --- a/packages/server/destinations/gcp/src/bigquery/writer.ts +++ b/packages/server/destinations/gcp/src/bigquery/writer.ts @@ -39,11 +39,12 @@ export interface OpenWriterArgs { // Raw passthrough auth/client options for the WriterClient (the escape hatch). bigquery?: BigQueryOptions; /** - * gRPC deadline in milliseconds, derived from the standard per-step - * `config.timeout`. Applied as the gax `CallOptions.timeout` on the appendRows - * bidi stream (via createStreamConnection) and the unary getWriteStream schema - * fetch, so a hanging call is cancelled by gRPC and rejects instead of running - * detached. + * Deadline in ms for unary control-plane calls (the getWriteStream schema + * fetch), derived from the standard per-step `config.timeout`. Never applied + * to the appendRows stream: a gax deadline on a bidi stream bounds the WHOLE + * stream lifetime, not one append. Per-delivery bounds live in the collector + * (it races every push at `config.timeout`); the stream stays on the SDK + * default so it can live for hours. */ timeout?: number; /** @@ -99,11 +100,13 @@ export async function openWriter( destinationTable, }); - // gax call options carrying the per-request deadline. The StreamConnection - // stores these and applies them to the underlying appendRows bidi stream, so - // every appendRows/getResult on this writer inherits the deadline. When the - // deadline fires, gRPC cancels the call and getResult() rejects (no detached - // promise). Left undefined when no timeout is configured. + // gax call options carrying the deadline for UNARY control-plane calls + // (the getWriteStream schema fetch). Never passed to createStreamConnection: + // a CallOptions.timeout on the appendRows bidi stream is the stream's TOTAL + // deadline, killing a healthy connection when it expires. The stream keeps + // the SDK's own long-lived default; individual deliveries are bounded by the + // collector's per-push race (config.timeout), not by a transport deadline. + // Left undefined when no timeout is configured. const callOptions: CallOptions | undefined = timeout === undefined ? undefined : { timeout }; @@ -125,13 +128,10 @@ export async function openWriter( // implicit `_default` stream without calling CreateWriteStream. Passing // managedwriter.DefaultStream as streamType triggers a CreateWriteStream // call with type='DEFAULT', which BQ rejects as TYPE_UNSPECIFIED. - connection = await writeClient.createStreamConnection( - { - destinationTable, - streamId: managedwriter.DefaultStream, - }, - callOptions, - ); + connection = await writeClient.createStreamConnection({ + destinationTable, + streamId: managedwriter.DefaultStream, + }); // Attach the connection-error listener on the StreamConnection (NOT the // inner gRPC `_connection`) BEFORE building the JSONWriter, so any `'error'` @@ -218,8 +218,10 @@ export interface EnsureWriterSettings { * them (each DLQ-routes correctly), and the memo clears in a finally so a * later push retries. * - * The single openWriter carries the gax CallOptions.timeout, so each attempt is - * time-bounded. pushBatch shares this function, so it inherits both bounds. + * A re-open's unary schema fetch is bounded by the gax CallOptions.timeout; + * the re-open as a whole runs in-band on a push, under the collector's + * per-delivery race. pushBatch shares this function, so it inherits both + * bounds. */ export function ensureWriter( settings: EnsureWriterSettings, diff --git a/packages/server/destinations/hubspot/CHANGELOG.md b/packages/server/destinations/hubspot/CHANGELOG.md index 11e7eadcd..aade12856 100644 --- a/packages/server/destinations/hubspot/CHANGELOG.md +++ b/packages/server/destinations/hubspot/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-hubspot +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/hubspot/package.json b/packages/server/destinations/hubspot/package.json index 36520e2cd..661538f11 100644 --- a/packages/server/destinations/hubspot/package.json +++ b/packages/server/destinations/hubspot/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-hubspot", "description": "HubSpot CRM server destination for walkerOS (@hubspot/api-client, custom events + contact upsert)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@hubspot/api-client": "^13.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/kafka/CHANGELOG.md b/packages/server/destinations/kafka/CHANGELOG.md index f6b0b0d6e..75d1ccae8 100644 --- a/packages/server/destinations/kafka/CHANGELOG.md +++ b/packages/server/destinations/kafka/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-kafka +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/kafka/package.json b/packages/server/destinations/kafka/package.json index 89f198b68..66594fc54 100644 --- a/packages/server/destinations/kafka/package.json +++ b/packages/server/destinations/kafka/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-kafka", "description": "Apache Kafka server destination for walkerOS (kafkajs, JSON serialization, GZIP compression)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "kafkajs": "^2.2.4", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/klaviyo/CHANGELOG.md b/packages/server/destinations/klaviyo/CHANGELOG.md index b73cc398a..418f5488e 100644 --- a/packages/server/destinations/klaviyo/CHANGELOG.md +++ b/packages/server/destinations/klaviyo/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-klaviyo +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/klaviyo/package.json b/packages/server/destinations/klaviyo/package.json index 411ded136..18367ee20 100644 --- a/packages/server/destinations/klaviyo/package.json +++ b/packages/server/destinations/klaviyo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-klaviyo", "description": "Klaviyo marketing automation server destination for walkerOS (klaviyo-api SDK, events + profile upserts)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "klaviyo-api": "^22.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/linkedin/CHANGELOG.md b/packages/server/destinations/linkedin/CHANGELOG.md index 4615e6a20..625a3ee11 100644 --- a/packages/server/destinations/linkedin/CHANGELOG.md +++ b/packages/server/destinations/linkedin/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-linkedin +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/linkedin/package.json b/packages/server/destinations/linkedin/package.json index 39ac46026..523c82a26 100644 --- a/packages/server/destinations/linkedin/package.json +++ b/packages/server/destinations/linkedin/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-linkedin", "description": "LinkedIn Conversions API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/meta/CHANGELOG.md b/packages/server/destinations/meta/CHANGELOG.md index 778ab484d..cfbe0d3ab 100644 --- a/packages/server/destinations/meta/CHANGELOG.md +++ b/packages/server/destinations/meta/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-meta +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/meta/package.json b/packages/server/destinations/meta/package.json index 76a0ca2e5..ba5145d05 100644 --- a/packages/server/destinations/meta/package.json +++ b/packages/server/destinations/meta/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-meta", "description": "Meta server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/mixpanel/CHANGELOG.md b/packages/server/destinations/mixpanel/CHANGELOG.md index 3307efc83..d76185353 100644 --- a/packages/server/destinations/mixpanel/CHANGELOG.md +++ b/packages/server/destinations/mixpanel/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-mixpanel +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/mixpanel/package.json b/packages/server/destinations/mixpanel/package.json index 5e3c7a66a..7751060a4 100644 --- a/packages/server/destinations/mixpanel/package.json +++ b/packages/server/destinations/mixpanel/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-mixpanel", "description": "Mixpanel server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "mixpanel": "^0.22.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/mparticle/CHANGELOG.md b/packages/server/destinations/mparticle/CHANGELOG.md index 2646eb7e0..f9ced78b1 100644 --- a/packages/server/destinations/mparticle/CHANGELOG.md +++ b/packages/server/destinations/mparticle/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-mparticle +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/mparticle/package.json b/packages/server/destinations/mparticle/package.json index b1ec51d98..4bc92dcee 100644 --- a/packages/server/destinations/mparticle/package.json +++ b/packages/server/destinations/mparticle/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-mparticle", "description": "mParticle server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/pinterest/CHANGELOG.md b/packages/server/destinations/pinterest/CHANGELOG.md index 2d109ce43..a215108ed 100644 --- a/packages/server/destinations/pinterest/CHANGELOG.md +++ b/packages/server/destinations/pinterest/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-pinterest +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/pinterest/package.json b/packages/server/destinations/pinterest/package.json index 89b736934..974a8a50a 100644 --- a/packages/server/destinations/pinterest/package.json +++ b/packages/server/destinations/pinterest/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-pinterest", "description": "Pinterest server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/posthog/CHANGELOG.md b/packages/server/destinations/posthog/CHANGELOG.md index 32d66d591..b3e802447 100644 --- a/packages/server/destinations/posthog/CHANGELOG.md +++ b/packages/server/destinations/posthog/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-posthog +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/posthog/package.json b/packages/server/destinations/posthog/package.json index 1dfc4de7f..9e81ad7e0 100644 --- a/packages/server/destinations/posthog/package.json +++ b/packages/server/destinations/posthog/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-posthog", "description": "PostHog server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "posthog-node": "^5.0.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/reddit/CHANGELOG.md b/packages/server/destinations/reddit/CHANGELOG.md index 32414aea5..af6e5b500 100644 --- a/packages/server/destinations/reddit/CHANGELOG.md +++ b/packages/server/destinations/reddit/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-reddit +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/reddit/package.json b/packages/server/destinations/reddit/package.json index 13d45890a..7d51b4939 100644 --- a/packages/server/destinations/reddit/package.json +++ b/packages/server/destinations/reddit/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-reddit", "description": "Reddit server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/redis/CHANGELOG.md b/packages/server/destinations/redis/CHANGELOG.md index 3075d5191..a82cae845 100644 --- a/packages/server/destinations/redis/CHANGELOG.md +++ b/packages/server/destinations/redis/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-redis +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/redis/package.json b/packages/server/destinations/redis/package.json index e6c6d8db5..f76159529 100644 --- a/packages/server/destinations/redis/package.json +++ b/packages/server/destinations/redis/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-redis", "description": "Redis Streams server destination for walkerOS (ioredis, XADD, pipeline batching)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "ioredis": "^5.10.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/rudderstack/CHANGELOG.md b/packages/server/destinations/rudderstack/CHANGELOG.md index e3d6d5f50..0580ccad3 100644 --- a/packages/server/destinations/rudderstack/CHANGELOG.md +++ b/packages/server/destinations/rudderstack/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-rudderstack +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/rudderstack/package.json b/packages/server/destinations/rudderstack/package.json index 26c000c6d..f96bfd10f 100644 --- a/packages/server/destinations/rudderstack/package.json +++ b/packages/server/destinations/rudderstack/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-rudderstack", "description": "RudderStack CDP server destination for walkerOS (@rudderstack/rudder-sdk-node, full Segment Spec)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@rudderstack/rudder-sdk-node": "^3.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/segment/CHANGELOG.md b/packages/server/destinations/segment/CHANGELOG.md index e061a62e3..20756d274 100644 --- a/packages/server/destinations/segment/CHANGELOG.md +++ b/packages/server/destinations/segment/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-segment +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/segment/package.json b/packages/server/destinations/segment/package.json index bc52686dc..8f1dc0ec1 100644 --- a/packages/server/destinations/segment/package.json +++ b/packages/server/destinations/segment/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-segment", "description": "Segment CDP server destination for walkerOS (@segment/analytics-node, full Segment Spec)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@segment/analytics-node": "^3.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/slack/CHANGELOG.md b/packages/server/destinations/slack/CHANGELOG.md index a1d51adb8..2ab6393b3 100644 --- a/packages/server/destinations/slack/CHANGELOG.md +++ b/packages/server/destinations/slack/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-slack +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/slack/package.json b/packages/server/destinations/slack/package.json index bfdb804d8..3ee1d240a 100644 --- a/packages/server/destinations/slack/package.json +++ b/packages/server/destinations/slack/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-slack", "description": "Slack server destination for walkerOS (Incoming Webhook + @slack/web-api, Block Kit, channel routing, threading, DMs)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -36,11 +36,11 @@ }, "dependencies": { "@slack/web-api": "^7.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/snapchat/CHANGELOG.md b/packages/server/destinations/snapchat/CHANGELOG.md index 587a5d9d3..5921a227e 100644 --- a/packages/server/destinations/snapchat/CHANGELOG.md +++ b/packages/server/destinations/snapchat/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-meta +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/snapchat/package.json b/packages/server/destinations/snapchat/package.json index 372164b48..731a893d7 100644 --- a/packages/server/destinations/snapchat/package.json +++ b/packages/server/destinations/snapchat/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-snapchat", "description": "Snapchat Conversions API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/sqlite/CHANGELOG.md b/packages/server/destinations/sqlite/CHANGELOG.md index ea43281a1..4305a7a37 100644 --- a/packages/server/destinations/sqlite/CHANGELOG.md +++ b/packages/server/destinations/sqlite/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-sqlite +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/sqlite/package.json b/packages/server/destinations/sqlite/package.json index 77f17249c..27492ed82 100644 --- a/packages/server/destinations/sqlite/package.json +++ b/packages/server/destinations/sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-sqlite", "description": "SQLite server destination for walkerOS (local via better-sqlite3, remote via libSQL/Turso)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "peerDependencies": { "better-sqlite3": "^12.0.0", @@ -54,7 +54,7 @@ } }, "devDependencies": { - "@walkeros/collector": "4.5.0", + "@walkeros/collector": "4.6.0", "better-sqlite3": "^12.0.0", "@libsql/client": "^0.17.0", "@types/better-sqlite3": "^7.6.13" diff --git a/packages/server/destinations/tiktok/CHANGELOG.md b/packages/server/destinations/tiktok/CHANGELOG.md index 3f90c1c3e..4dfcfc8b5 100644 --- a/packages/server/destinations/tiktok/CHANGELOG.md +++ b/packages/server/destinations/tiktok/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-meta +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/tiktok/package.json b/packages/server/destinations/tiktok/package.json index 5048f9a99..658c2a4d7 100644 --- a/packages/server/destinations/tiktok/package.json +++ b/packages/server/destinations/tiktok/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-tiktok", "description": "TikTok Events API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,11 +35,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/destinations/twitter/CHANGELOG.md b/packages/server/destinations/twitter/CHANGELOG.md index 6c98c57be..1811029f9 100644 --- a/packages/server/destinations/twitter/CHANGELOG.md +++ b/packages/server/destinations/twitter/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-destination-twitter +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/destinations/twitter/package.json b/packages/server/destinations/twitter/package.json index f89734d78..acae3e2f3 100644 --- a/packages/server/destinations/twitter/package.json +++ b/packages/server/destinations/twitter/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-destination-twitter", "description": "X (Twitter) Conversions API server destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "exports": { ".": { @@ -35,12 +35,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0", + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0", "oauth-1.0a": "^2.2.6" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/sources/aws/CHANGELOG.md b/packages/server/sources/aws/CHANGELOG.md index c1acc2e40..96235ddf5 100644 --- a/packages/server/sources/aws/CHANGELOG.md +++ b/packages/server/sources/aws/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-source-aws +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/sources/aws/package.json b/packages/server/sources/aws/package.json index 87416b243..bdfa979ab 100644 --- a/packages/server/sources/aws/package.json +++ b/packages/server/sources/aws/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-aws", "description": "AWS server sources for walkerOS (Lambda, API Gateway, Function URLs)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -23,14 +23,14 @@ "dependencies": { "@aws-sdk/client-sqs": "^3.952.0", "@aws-sdk/client-sns": "^3.952.0", - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "peerDependencies": { "@types/aws-lambda": "^8.10.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.159", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/sources/express/CHANGELOG.md b/packages/server/sources/express/CHANGELOG.md index 517ff5b1e..98436fe08 100644 --- a/packages/server/sources/express/CHANGELOG.md +++ b/packages/server/sources/express/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/server-source-express +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/server/sources/express/package.json b/packages/server/sources/express/package.json index 37700468e..942eabe72 100644 --- a/packages/server/sources/express/package.json +++ b/packages/server/sources/express/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-express", "description": "Express server source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -20,8 +20,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", "express": "^5.2.1", "cors": "^2.8.5" }, diff --git a/packages/server/sources/fetch/CHANGELOG.md b/packages/server/sources/fetch/CHANGELOG.md index d2c2f75ef..4f56f00f1 100644 --- a/packages/server/sources/fetch/CHANGELOG.md +++ b/packages/server/sources/fetch/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/server-source-fetch +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/sources/fetch/package.json b/packages/server/sources/fetch/package.json index 8be52a741..6113fb63b 100644 --- a/packages/server/sources/fetch/package.json +++ b/packages/server/sources/fetch/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-fetch", "description": "Web Standard Fetch API source for walkerOS (Cloudflare Workers, Vercel Edge, Deno, Bun)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -20,8 +20,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/sources/gcp/CHANGELOG.md b/packages/server/sources/gcp/CHANGELOG.md index b0467f0c5..84d1d5fae 100644 --- a/packages/server/sources/gcp/CHANGELOG.md +++ b/packages/server/sources/gcp/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/server-source-gcp +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/sources/gcp/package.json b/packages/server/sources/gcp/package.json index 8f17d7cd8..6dc5c7e0d 100644 --- a/packages/server/sources/gcp/package.json +++ b/packages/server/sources/gcp/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-source-gcp", "description": "Google Cloud Platform server sources for walkerOS (Cloud Functions, Pub/Sub)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -21,8 +21,8 @@ }, "dependencies": { "@google-cloud/pubsub": "^5.3.0", - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "peerDependencies": { "@google-cloud/functions-framework": "^5.0.2" diff --git a/packages/server/stores/fs/CHANGELOG.md b/packages/server/stores/fs/CHANGELOG.md index 54b1caef4..cd9fa7d8c 100644 --- a/packages/server/stores/fs/CHANGELOG.md +++ b/packages/server/stores/fs/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-store-fs +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/stores/fs/package.json b/packages/server/stores/fs/package.json index 4996d21cd..fe0bc0980 100644 --- a/packages/server/stores/fs/package.json +++ b/packages/server/stores/fs/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-fs", "description": "Filesystem store for walkerOS server - reads and writes files via the Store interface", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,10 +33,10 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/stores/gcs/CHANGELOG.md b/packages/server/stores/gcs/CHANGELOG.md index f3649f9b3..a96ef99bd 100644 --- a/packages/server/stores/gcs/CHANGELOG.md +++ b/packages/server/stores/gcs/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-store-gcs +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/stores/gcs/package.json b/packages/server/stores/gcs/package.json index ebcfcec23..b72ab5fcf 100644 --- a/packages/server/stores/gcs/package.json +++ b/packages/server/stores/gcs/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-gcs", "description": "Google Cloud Storage for walkerOS server flows", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/stores/s3/CHANGELOG.md b/packages/server/stores/s3/CHANGELOG.md index f87956cf3..c1109dbd4 100644 --- a/packages/server/stores/s3/CHANGELOG.md +++ b/packages/server/stores/s3/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-store-s3 +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/stores/s3/package.json b/packages/server/stores/s3/package.json index 599ab36ff..635be40ef 100644 --- a/packages/server/stores/s3/package.json +++ b/packages/server/stores/s3/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-s3", "description": "S3-compatible object storage for walkerOS server flows", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "s3mini": "^0.9.1" }, "devDependencies": {}, diff --git a/packages/server/stores/sheets/CHANGELOG.md b/packages/server/stores/sheets/CHANGELOG.md index 9b16b9b24..1747d375d 100644 --- a/packages/server/stores/sheets/CHANGELOG.md +++ b/packages/server/stores/sheets/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-store-sheets +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/stores/sheets/package.json b/packages/server/stores/sheets/package.json index 84592f87e..36b9129c8 100644 --- a/packages/server/stores/sheets/package.json +++ b/packages/server/stores/sheets/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-store-sheets", "description": "Google Sheets store for walkerOS server flows", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/server/transformers/bot/CHANGELOG.md b/packages/server/transformers/bot/CHANGELOG.md index 292cfced4..0acd7f714 100644 --- a/packages/server/transformers/bot/CHANGELOG.md +++ b/packages/server/transformers/bot/CHANGELOG.md @@ -1,5 +1,16 @@ # @walkeros/server-transformer-bot +## 4.6.0 + +### Patch Changes + +- 403ff6c: Bot detection now names nine more uptime and synthetic monitoring + services, including Uptrends, Site24x7, Datadog Synthetics, New Relic + Synthetics and Better Stack. Their requests report as `monitor` with the + product name instead of generic automation, and several that previously passed + as human traffic are now flagged. + - @walkeros/core@4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/server/transformers/bot/README.md b/packages/server/transformers/bot/README.md index 416f94dac..10168692e 100644 --- a/packages/server/transformers/bot/README.md +++ b/packages/server/transformers/bot/README.md @@ -118,7 +118,7 @@ should point `settings.output.botReasons` at an event path instead. | `automation` | isbot match, missing UA, or a value impossible for the pinned context | 70-80 | | `search-crawler` | search engine crawler (Googlebot, bingbot, Applebot, YandexBot, Baiduspider, PetalBot, …) | 90 | | `seo-tool` | commercial SEO crawler (AhrefsBot, SemrushBot, DotBot, MJ12bot, Screaming Frog) | 90 | -| `monitor` | uptime and synthetic monitoring, usually your own infrastructure (UptimeRobot, Pingdom, StatusCake) | 90 | +| `monitor` | uptime and synthetic monitoring, usually your own infrastructure (UptimeRobot, Pingdom, Uptrends, Site24x7, Datadog Synthetics, ...) | 90 | | `link-preview` | link unfurler, meaning a person just shared this URL (facebookexternalhit, Twitterbot, LinkedInBot, Slackbot, Discordbot, TelegramBot, WhatsApp) | 90 | | `ai-agent` | AI agent acting for a person (ChatGPT-User, Claude-User, Perplexity-User, Google-Agent) | 90 | | `ai-crawler` | AI training or search-index crawler (GPTBot, ClaudeBot, CCBot, OAI-SearchBot) | 90 | diff --git a/packages/server/transformers/bot/package.json b/packages/server/transformers/bot/package.json index 3788a1e11..d57fc4b48 100644 --- a/packages/server/transformers/bot/package.json +++ b/packages/server/transformers/bot/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-bot", "description": "Server-side bot and AI-agent detection transformer for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -28,7 +28,7 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "isbot": "^5.1.39" }, "repository": { diff --git a/packages/server/transformers/bot/src/__tests__/detect-ua.test.ts b/packages/server/transformers/bot/src/__tests__/detect-ua.test.ts index 7e3075b36..7f6baefaa 100644 --- a/packages/server/transformers/bot/src/__tests__/detect-ua.test.ts +++ b/packages/server/transformers/bot/src/__tests__/detect-ua.test.ts @@ -137,6 +137,85 @@ describe('detectCrawler', () => { }); }); +describe('detectCrawler monitors', () => { + it.each([ + [ + 'Uptrends', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 uptrends', + 'Uptrends', + ], + [ + 'Site24x7', + 'Mozilla/5.0 (compatible; Site24x7/1.0; +https://www.site24x7.com/)', + 'Site24x7', + ], + ['Datadog Synthetics API test', 'Datadog/Synthetics', 'Datadog Synthetics'], + [ + 'Datadog Synthetics browser test', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 DatadogSynthetics', + 'Datadog Synthetics', + ], + [ + 'New Relic Synthetics', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36 NewRelicSynthetics/1.0', + 'New Relic Synthetics', + ], + [ + 'Better Stack', + 'Better Uptime Bot Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', + 'Better Stack', + ], + [ + 'HetrixTools', + 'HetrixTools Uptime Monitoring Bot. https://hetrix.tools/uptime-monitoring-bot.html', + 'HetrixTools', + ], + [ + 'updown.io', + 'Mozilla/5.0 (compatible; updown.io daemon 2.4)', + 'updown.io', + ], + [ + 'Oh Dear', + 'Mozilla/5.0 (compatible; OhDear/1.1; +https://ohdear.app/checker)', + 'Oh Dear', + ], + [ + 'GTmetrix', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 GTmetrix', + 'GTmetrix', + ], + ])('%s resolves to the named monitor %s', (_, ua, product) => { + expect(detectCrawler(ua)).toMatchObject({ product, category: 'monitor' }); + }); + + test('Better Uptime Bot does not shadow UptimeRobot', () => { + expect( + detectCrawler( + 'Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)', + )?.product, + ).toBe('UptimeRobot'); + }); +}); + +describe('crawler token reachability', () => { + // An entry whose token contains an earlier entry's token can never fire: the + // earlier, broader row always wins the first-hit scan. + test('no entry is shadowed by an earlier, broader token', () => { + const shadowed = crawlers + .filter((entry, index) => + crawlers + .slice(0, index) + .some((earlier) => + entry.match.toLowerCase().includes(earlier.match.toLowerCase()), + ), + ) + .map((entry) => entry.match); + + expect(shadowed).toEqual([]); + }); +}); + describe('parseUAFamily', () => { it.each([ [ diff --git a/packages/server/transformers/bot/src/__tests__/score.test.ts b/packages/server/transformers/bot/src/__tests__/score.test.ts index fe9f533f7..ea60aa799 100644 --- a/packages/server/transformers/bot/src/__tests__/score.test.ts +++ b/packages/server/transformers/bot/src/__tests__/score.test.ts @@ -109,6 +109,12 @@ describe('deterministic ladder', () => { 'monitor', 'UptimeRobot', ], + [ + 'Uptrends', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 uptrends', + 'monitor', + 'Uptrends', + ], [ 'facebookexternalhit', 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', diff --git a/packages/server/transformers/bot/src/data/crawlers.ts b/packages/server/transformers/bot/src/data/crawlers.ts index e880708df..8766e599f 100644 --- a/packages/server/transformers/bot/src/data/crawlers.ts +++ b/packages/server/transformers/bot/src/data/crawlers.ts @@ -43,14 +43,26 @@ * Majestic: https://mj12bot.com/ * Screaming Frog: https://www.screamingfrog.co.uk/seo-spider/user-guide/configuration/ * UptimeRobot: https://help.uptimerobot.com/en/articles/11358489-what-is-the-uptimerobot-user-agent-string + * Datadog: https://docs.datadoghq.com/synthetics/guide/identify_synthetics_bots/ + * Better Stack: https://betterstack.com/docs/uptime/frequently-asked-questions/ + * HetrixTools: https://hetrixtools.com/uptime-monitor-bot/ + * updown.io: https://updown.io/about + * GTmetrix: https://gtmetrix.com/blog/anonymizing-your-user-agent/ * Meta: https://developers.facebook.com/docs/sharing/webmasters/web-crawlers * X: https://developer.x.com/en/docs/x-for-websites/cards/guides/getting-started * Slack: https://api.slack.com/robots * WhatsApp: https://developers.facebook.com/documentation/business-messaging/whatsapp/link-previews/ * * Entries without a readable vendor page (SeznamBot, Yeti, Discordbot, - * TelegramBot, Pingdom, StatusCake, DotBot) rest on the self-reference in the - * UA string itself. Reviewed quarterly. + * TelegramBot, Pingdom, StatusCake, DotBot, Site24x7, New Relic Synthetics, + * Oh Dear) rest on the self-reference in the UA string itself. `Uptrends` + * additionally rests on production traffic observed carrying the token. + * Reviewed quarterly. + * + * Monitor tokens are opt-out-able in a way search-crawler tokens are not: + * Uptrends, Datadog and GTmetrix all let an operator override the UA to a + * plain browser string, and a monitor configured that way is indistinguishable + * from a person here. A miss is expected; a false positive is not. * * Deliberately excluded: `Googlebot-News`, `Google-Extended` and * `Applebot-Extended` are robots.txt directives that never appear in a UA @@ -139,6 +151,32 @@ export const crawlers: CrawlerEntry[] = [ { match: 'UptimeRobot', product: 'UptimeRobot', category: 'monitor' }, { match: 'Pingdom', product: 'Pingdom', category: 'monitor' }, { match: 'StatusCake', product: 'StatusCake', category: 'monitor' }, + { match: 'Uptrends', product: 'Uptrends', category: 'monitor' }, + { match: 'Site24x7', product: 'Site24x7', category: 'monitor' }, + // Datadog's two check types carry two different tokens, neither a substring + // of the other: API tests send `Datadog/Synthetics`, browser tests append + // `DatadogSynthetics` to a real browser UA. + { + match: 'DatadogSynthetics', + product: 'Datadog Synthetics', + category: 'monitor', + }, + { + match: 'Datadog/Synthetics', + product: 'Datadog Synthetics', + category: 'monitor', + }, + { + match: 'NewRelicSynthetics', + product: 'New Relic Synthetics', + category: 'monitor', + }, + // Token, not product name: Better Stack still ships the Better Uptime UA. + { match: 'Better Uptime Bot', product: 'Better Stack', category: 'monitor' }, + { match: 'HetrixTools', product: 'HetrixTools', category: 'monitor' }, + { match: 'updown.io', product: 'updown.io', category: 'monitor' }, + { match: 'OhDear', product: 'Oh Dear', category: 'monitor' }, + { match: 'GTmetrix', product: 'GTmetrix', category: 'monitor' }, // --- Link unfurlers --- { diff --git a/packages/server/transformers/file/CHANGELOG.md b/packages/server/transformers/file/CHANGELOG.md index f61cb959c..51c61e304 100644 --- a/packages/server/transformers/file/CHANGELOG.md +++ b/packages/server/transformers/file/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/server-transformer-file +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/transformers/file/package.json b/packages/server/transformers/file/package.json index 7ca47655f..f579c7fde 100644 --- a/packages/server/transformers/file/package.json +++ b/packages/server/transformers/file/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-file", "description": "File serving transformer for walkerOS - serves static files via pluggable Store backend", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -28,10 +28,10 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/server/transformers/fingerprint/CHANGELOG.md b/packages/server/transformers/fingerprint/CHANGELOG.md index 9cabad25d..1a6625d20 100644 --- a/packages/server/transformers/fingerprint/CHANGELOG.md +++ b/packages/server/transformers/fingerprint/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/server-transformer-fingerprint +## 4.6.0 + +### Patch Changes + +- @walkeros/server-core@4.6.0 +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/server/transformers/fingerprint/package.json b/packages/server/transformers/fingerprint/package.json index 0cf160e1e..5db2665e3 100644 --- a/packages/server/transformers/fingerprint/package.json +++ b/packages/server/transformers/fingerprint/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/server-transformer-fingerprint", "description": "Fingerprint transformer for walkerOS server - hash configurable fields for session continuity", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,8 +27,8 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -60,7 +60,7 @@ } ], "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/server-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/server-core": "4.6.0" } } diff --git a/packages/transformers/demo/CHANGELOG.md b/packages/transformers/demo/CHANGELOG.md index 556b33673..1baa88e4c 100644 --- a/packages/transformers/demo/CHANGELOG.md +++ b/packages/transformers/demo/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/transformer-demo +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/transformers/demo/package.json b/packages/transformers/demo/package.json index 9082ee196..1b638db6e 100644 --- a/packages/transformers/demo/package.json +++ b/packages/transformers/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-demo", "description": "Demo transformer for walkerOS - logs and passes through events", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -26,7 +26,7 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/transformers/ga4/CHANGELOG.md b/packages/transformers/ga4/CHANGELOG.md index 7e6f20c16..0d3d9bf84 100644 --- a/packages/transformers/ga4/CHANGELOG.md +++ b/packages/transformers/ga4/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/transformer-ga4 +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/transformers/ga4/package.json b/packages/transformers/ga4/package.json index b753b6fc2..ab2cc5bfd 100644 --- a/packages/transformers/ga4/package.json +++ b/packages/transformers/ga4/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-ga4", "description": "Decodes GA4 Measurement Protocol v2 (gtag /g/collect) into walkerOS events", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -32,10 +32,10 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/transformers/validate/CHANGELOG.md b/packages/transformers/validate/CHANGELOG.md index 29f94e393..f77708aba 100644 --- a/packages/transformers/validate/CHANGELOG.md +++ b/packages/transformers/validate/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/transformer-validate +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/transformers/validate/package.json b/packages/transformers/validate/package.json index 61b4f1c2a..f41aa04c8 100644 --- a/packages/transformers/validate/package.json +++ b/packages/transformers/validate/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/transformer-validate", "description": "JSON Schema contract validation transformer for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,11 +33,11 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "@cfworker/json-schema": "^4.1.1" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/core/CHANGELOG.md b/packages/web/core/CHANGELOG.md index a8e780a64..4da9675ba 100644 --- a/packages/web/core/CHANGELOG.md +++ b/packages/web/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/web-core +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/core/package.json b/packages/web/core/package.json index 3c5b68142..5d7e4e0a8 100644 --- a/packages/web/core/package.json +++ b/packages/web/core/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-core", "description": "Web-specific utilities for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -27,7 +27,7 @@ "update": "npx npm-check-updates -u && npm update" }, "devDependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", @@ -51,6 +51,6 @@ } ], "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" } } diff --git a/packages/web/destinations/amplitude/CHANGELOG.md b/packages/web/destinations/amplitude/CHANGELOG.md index c9dbdf1fc..4cf83bd94 100644 --- a/packages/web/destinations/amplitude/CHANGELOG.md +++ b/packages/web/destinations/amplitude/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-amplitude +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/amplitude/package.json b/packages/web/destinations/amplitude/package.json index 093634a3a..2f4083db8 100644 --- a/packages/web/destinations/amplitude/package.json +++ b/packages/web/destinations/amplitude/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-amplitude", "description": "Amplitude web destination for walkerOS (analytics, session replay, experiments, guides & surveys)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@amplitude/unified": "^1.0.16", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/api/CHANGELOG.md b/packages/web/destinations/api/CHANGELOG.md index 17458bc9a..ee56e565b 100644 --- a/packages/web/destinations/api/CHANGELOG.md +++ b/packages/web/destinations/api/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-api +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/api/package.json b/packages/web/destinations/api/package.json index ff0472f92..6092d5ccb 100644 --- a/packages/web/destinations/api/package.json +++ b/packages/web/destinations/api/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-api", "description": "Web API destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/clarity/CHANGELOG.md b/packages/web/destinations/clarity/CHANGELOG.md index a7d41859a..fd58b7034 100644 --- a/packages/web/destinations/clarity/CHANGELOG.md +++ b/packages/web/destinations/clarity/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-clarity +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/clarity/package.json b/packages/web/destinations/clarity/package.json index 5dfca5fd0..19c10f030 100644 --- a/packages/web/destinations/clarity/package.json +++ b/packages/web/destinations/clarity/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-clarity", "description": "Microsoft Clarity web destination for walkerOS (session replay, heatmaps, smart events)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@microsoft/clarity": "^1.0.2", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/d8a/CHANGELOG.md b/packages/web/destinations/d8a/CHANGELOG.md index ec9b75058..0d347de96 100644 --- a/packages/web/destinations/d8a/CHANGELOG.md +++ b/packages/web/destinations/d8a/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-d8a +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/d8a/package.json b/packages/web/destinations/d8a/package.json index ba0cc408d..1a2925942 100644 --- a/packages/web/destinations/d8a/package.json +++ b/packages/web/destinations/d8a/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-d8a", "description": "d8a web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ }, "dependencies": { "@d8a-tech/wt": "^1.2.1", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/fullstory/CHANGELOG.md b/packages/web/destinations/fullstory/CHANGELOG.md index f82865cda..93f24780c 100644 --- a/packages/web/destinations/fullstory/CHANGELOG.md +++ b/packages/web/destinations/fullstory/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-fullstory +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/fullstory/package.json b/packages/web/destinations/fullstory/package.json index a184f654f..7561a026e 100644 --- a/packages/web/destinations/fullstory/package.json +++ b/packages/web/destinations/fullstory/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-fullstory", "description": "FullStory web destination for walkerOS (session replay, custom events, user/page properties)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@fullstory/browser": "^2.0.8", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/gtag/CHANGELOG.md b/packages/web/destinations/gtag/CHANGELOG.md index 7bf01c4a5..86a35216d 100644 --- a/packages/web/destinations/gtag/CHANGELOG.md +++ b/packages/web/destinations/gtag/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-gtag +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Minor Changes diff --git a/packages/web/destinations/gtag/package.json b/packages/web/destinations/gtag/package.json index dae9b2eaf..b2fbb2f9b 100644 --- a/packages/web/destinations/gtag/package.json +++ b/packages/web/destinations/gtag/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-gtag", "description": "Unified Google destination for walkerOS (GA4, Ads, GTM)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/heap/CHANGELOG.md b/packages/web/destinations/heap/CHANGELOG.md index 10cdd6d61..5cb3354ce 100644 --- a/packages/web/destinations/heap/CHANGELOG.md +++ b/packages/web/destinations/heap/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-heap +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/heap/package.json b/packages/web/destinations/heap/package.json index 506afaea1..7db13a4cb 100644 --- a/packages/web/destinations/heap/package.json +++ b/packages/web/destinations/heap/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-heap", "description": "Heap web destination for walkerOS (product analytics, auto-capture)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/hotjar/CHANGELOG.md b/packages/web/destinations/hotjar/CHANGELOG.md index 9e0fd2a31..b5e6fecb2 100644 --- a/packages/web/destinations/hotjar/CHANGELOG.md +++ b/packages/web/destinations/hotjar/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-hotjar +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/hotjar/package.json b/packages/web/destinations/hotjar/package.json index 9aca3359e..b9afa65c7 100644 --- a/packages/web/destinations/hotjar/package.json +++ b/packages/web/destinations/hotjar/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-hotjar", "description": "Hotjar web destination for walkerOS (session replay, heatmaps, surveys)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@hotjar/browser": "^1.0.9", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/linkedin/CHANGELOG.md b/packages/web/destinations/linkedin/CHANGELOG.md index 3c31e7955..2d4b11c06 100644 --- a/packages/web/destinations/linkedin/CHANGELOG.md +++ b/packages/web/destinations/linkedin/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-linkedin +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/linkedin/package.json b/packages/web/destinations/linkedin/package.json index 23047bea6..92cdbc3a6 100644 --- a/packages/web/destinations/linkedin/package.json +++ b/packages/web/destinations/linkedin/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-linkedin", "description": "LinkedIn Insight Tag web destination for walkerOS (conversion tracking, retargeting, demographic insights)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/matomo/CHANGELOG.md b/packages/web/destinations/matomo/CHANGELOG.md index b2a7b1ea9..e5973aa57 100644 --- a/packages/web/destinations/matomo/CHANGELOG.md +++ b/packages/web/destinations/matomo/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-matomo +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/matomo/package.json b/packages/web/destinations/matomo/package.json index e07f75b3d..54285d4a4 100644 --- a/packages/web/destinations/matomo/package.json +++ b/packages/web/destinations/matomo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-matomo", "description": "Matomo web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/meta/CHANGELOG.md b/packages/web/destinations/meta/CHANGELOG.md index f1e768dab..fe4afa046 100644 --- a/packages/web/destinations/meta/CHANGELOG.md +++ b/packages/web/destinations/meta/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-meta +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/meta/package.json b/packages/web/destinations/meta/package.json index 4b0a68f2e..2b186f46a 100644 --- a/packages/web/destinations/meta/package.json +++ b/packages/web/destinations/meta/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-meta", "description": "Meta pixel web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { "@types/facebook-pixel": "^0.0.31", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/mixpanel/CHANGELOG.md b/packages/web/destinations/mixpanel/CHANGELOG.md index 3e6887152..45ff64543 100644 --- a/packages/web/destinations/mixpanel/CHANGELOG.md +++ b/packages/web/destinations/mixpanel/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-mixpanel +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/mixpanel/package.json b/packages/web/destinations/mixpanel/package.json index 2d4f226c7..c7954761c 100644 --- a/packages/web/destinations/mixpanel/package.json +++ b/packages/web/destinations/mixpanel/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-mixpanel", "description": "Mixpanel web destination for walkerOS (events, people, groups, consent)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,13 +38,13 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "mixpanel-browser": "^2.78.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/web-core": "4.6.0" }, "devDependencies": { "@types/mixpanel-browser": "^2.50.0", - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/optimizely/CHANGELOG.md b/packages/web/destinations/optimizely/CHANGELOG.md index 1f96cc53a..7a4488384 100644 --- a/packages/web/destinations/optimizely/CHANGELOG.md +++ b/packages/web/destinations/optimizely/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-optimizely +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/optimizely/package.json b/packages/web/destinations/optimizely/package.json index 5f0f84ade..d5ac07530 100644 --- a/packages/web/destinations/optimizely/package.json +++ b/packages/web/destinations/optimizely/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-optimizely", "description": "Optimizely Feature Experimentation web destination for walkerOS (conversion tracking, revenue metrics, user targeting)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@optimizely/optimizely-sdk": "^6.0.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/piano/CHANGELOG.md b/packages/web/destinations/piano/CHANGELOG.md index 2b6ceffa8..1d3eedd45 100644 --- a/packages/web/destinations/piano/CHANGELOG.md +++ b/packages/web/destinations/piano/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-piano +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/piano/package.json b/packages/web/destinations/piano/package.json index 3ca630ac7..bba9d90dd 100644 --- a/packages/web/destinations/piano/package.json +++ b/packages/web/destinations/piano/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-piano", "description": "Piano Analytics web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/pinterest/CHANGELOG.md b/packages/web/destinations/pinterest/CHANGELOG.md index 0c1cd8bb6..706c55cf9 100644 --- a/packages/web/destinations/pinterest/CHANGELOG.md +++ b/packages/web/destinations/pinterest/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-pinterest +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/pinterest/package.json b/packages/web/destinations/pinterest/package.json index 9bba33336..f8a974ba2 100644 --- a/packages/web/destinations/pinterest/package.json +++ b/packages/web/destinations/pinterest/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-pinterest", "description": "Pinterest Tag web destination for walkerOS (conversion tracking, enhanced matching, audience building)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/piwikpro/CHANGELOG.md b/packages/web/destinations/piwikpro/CHANGELOG.md index ee4bd07b0..65438b900 100644 --- a/packages/web/destinations/piwikpro/CHANGELOG.md +++ b/packages/web/destinations/piwikpro/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-piwikpro +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/piwikpro/package.json b/packages/web/destinations/piwikpro/package.json index 6f0def993..1511bfc02 100644 --- a/packages/web/destinations/piwikpro/package.json +++ b/packages/web/destinations/piwikpro/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-piwikpro", "description": "Piwik PRO destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/plausible/CHANGELOG.md b/packages/web/destinations/plausible/CHANGELOG.md index 1a2d23d5c..7d2ceba8f 100644 --- a/packages/web/destinations/plausible/CHANGELOG.md +++ b/packages/web/destinations/plausible/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-plausible +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/plausible/package.json b/packages/web/destinations/plausible/package.json index 002a5c489..bddd6bd38 100644 --- a/packages/web/destinations/plausible/package.json +++ b/packages/web/destinations/plausible/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-plausible", "description": "Plausible web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/posthog/CHANGELOG.md b/packages/web/destinations/posthog/CHANGELOG.md index bd729584b..a4849893c 100644 --- a/packages/web/destinations/posthog/CHANGELOG.md +++ b/packages/web/destinations/posthog/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-posthog +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/posthog/package.json b/packages/web/destinations/posthog/package.json index 70fe790bd..a5b4468ee 100644 --- a/packages/web/destinations/posthog/package.json +++ b/packages/web/destinations/posthog/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-posthog", "description": "PostHog web destination for walkerOS (product analytics, session replay, feature flags, surveys)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", + "@walkeros/core": "4.6.0", "posthog-js": "^1.367.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/segment/CHANGELOG.md b/packages/web/destinations/segment/CHANGELOG.md index 99903d6a5..5c19a26db 100644 --- a/packages/web/destinations/segment/CHANGELOG.md +++ b/packages/web/destinations/segment/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-segment +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/segment/package.json b/packages/web/destinations/segment/package.json index e89fbf7a0..4fe9ac55c 100644 --- a/packages/web/destinations/segment/package.json +++ b/packages/web/destinations/segment/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-segment", "description": "Segment CDP web destination for walkerOS (@segment/analytics-next, full Segment Spec)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,11 +39,11 @@ }, "dependencies": { "@segment/analytics-next": "^1.82.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/destinations/snowplow/CHANGELOG.md b/packages/web/destinations/snowplow/CHANGELOG.md index 0f4d9a703..5a80d3f52 100644 --- a/packages/web/destinations/snowplow/CHANGELOG.md +++ b/packages/web/destinations/snowplow/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-snowplow +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/snowplow/package.json b/packages/web/destinations/snowplow/package.json index cb396f0b0..3462b1540 100644 --- a/packages/web/destinations/snowplow/package.json +++ b/packages/web/destinations/snowplow/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-snowplow", "description": "Snowplow web destination for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,12 +38,12 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/config": "4.5.0", + "@walkeros/collector": "4.6.0", + "@walkeros/config": "4.6.0", "@snowplow/browser-tracker-core": "^4.6.8", "@snowplow/browser-plugin-snowplow-ecommerce": "^4.6.8" }, diff --git a/packages/web/destinations/tiktok/CHANGELOG.md b/packages/web/destinations/tiktok/CHANGELOG.md index 1cd2a587a..b5bdf596a 100644 --- a/packages/web/destinations/tiktok/CHANGELOG.md +++ b/packages/web/destinations/tiktok/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-destination-tiktok +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/destinations/tiktok/package.json b/packages/web/destinations/tiktok/package.json index 1d172e55f..cecc1043c 100644 --- a/packages/web/destinations/tiktok/package.json +++ b/packages/web/destinations/tiktok/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-destination-tiktok", "description": "TikTok Pixel web destination for walkerOS (conversion tracking, Advanced Matching)", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,11 +38,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/browser/CHANGELOG.md b/packages/web/sources/browser/CHANGELOG.md index 1351fee08..0807e9b5b 100644 --- a/packages/web/sources/browser/CHANGELOG.md +++ b/packages/web/sources/browser/CHANGELOG.md @@ -1,5 +1,14 @@ # @walkeros/web-source-browser +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/browser/package.json b/packages/web/sources/browser/package.json index 3eb90f147..7252b94e5 100644 --- a/packages/web/sources/browser/package.json +++ b/packages/web/sources/browser/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-browser", "description": "Browser DOM source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,9 +33,9 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/cmps/cookiefirst/CHANGELOG.md b/packages/web/sources/cmps/cookiefirst/CHANGELOG.md index 148dac185..04293ddf9 100644 --- a/packages/web/sources/cmps/cookiefirst/CHANGELOG.md +++ b/packages/web/sources/cmps/cookiefirst/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/web-source-cmp-cookiefirst +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/cmps/cookiefirst/package.json b/packages/web/sources/cmps/cookiefirst/package.json index 1fa245da6..c67bccaf8 100644 --- a/packages/web/sources/cmps/cookiefirst/package.json +++ b/packages/web/sources/cmps/cookiefirst/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-cookiefirst", "description": "CookieFirst consent management source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/collector": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/collector": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/web/sources/cmps/cookiepro/CHANGELOG.md b/packages/web/sources/cmps/cookiepro/CHANGELOG.md index 9e6b9af33..08355e81b 100644 --- a/packages/web/sources/cmps/cookiepro/CHANGELOG.md +++ b/packages/web/sources/cmps/cookiepro/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/web-source-cmp-cookiepro +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/cmps/cookiepro/package.json b/packages/web/sources/cmps/cookiepro/package.json index 3648abc0f..85e7251df 100644 --- a/packages/web/sources/cmps/cookiepro/package.json +++ b/packages/web/sources/cmps/cookiepro/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-cookiepro", "description": "CookiePro/OneTrust consent management source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/collector": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/collector": "4.6.0" }, "devDependencies": {}, "repository": { diff --git a/packages/web/sources/cmps/usercentrics/CHANGELOG.md b/packages/web/sources/cmps/usercentrics/CHANGELOG.md index 4c654ef9f..a8f60dcfd 100644 --- a/packages/web/sources/cmps/usercentrics/CHANGELOG.md +++ b/packages/web/sources/cmps/usercentrics/CHANGELOG.md @@ -1,5 +1,13 @@ # @walkeros/web-source-cmp-usercentrics +## 4.6.0 + +### Patch Changes + +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/cmps/usercentrics/package.json b/packages/web/sources/cmps/usercentrics/package.json index 6f03772fd..c0f708c6e 100644 --- a/packages/web/sources/cmps/usercentrics/package.json +++ b/packages/web/sources/cmps/usercentrics/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-cmp-usercentrics", "description": "Usercentrics consent management source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "walkerOS": { "type": "source", @@ -46,8 +46,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/collector": "4.5.0", - "@walkeros/core": "4.5.0" + "@walkeros/collector": "4.6.0", + "@walkeros/core": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/dataLayer/CHANGELOG.md b/packages/web/sources/dataLayer/CHANGELOG.md index d51789292..cbcaf986b 100644 --- a/packages/web/sources/dataLayer/CHANGELOG.md +++ b/packages/web/sources/dataLayer/CHANGELOG.md @@ -1,5 +1,18 @@ # @walkeros/web-source-datalayer +## 4.6.0 + +### Patch Changes + +- 403ff6c: The dataLayer source now stamps every event it captures with its own + identity, so events arrive carrying `source.type: 'dataLayer'` and + `source.platform: 'web'` instead of defaulting to the collector. Destinations + that echo events back into the dataLayer can now guard against feedback loops, + and mappings can tell dataLayer-captured events apart. +- Updated dependencies [8802281] + - @walkeros/collector@4.6.0 + - @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/dataLayer/package.json b/packages/web/sources/dataLayer/package.json index edc65fb3a..687e47d1a 100644 --- a/packages/web/sources/dataLayer/package.json +++ b/packages/web/sources/dataLayer/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-datalayer", "description": "DataLayer source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -38,8 +38,8 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/collector": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/collector": "4.6.0" }, "devDependencies": { "@types/gtag.js": "^0.0.20" diff --git a/packages/web/sources/dataLayer/src/__tests__/sourceIdentity.test.ts b/packages/web/sources/dataLayer/src/__tests__/sourceIdentity.test.ts new file mode 100644 index 000000000..168a36827 --- /dev/null +++ b/packages/web/sources/dataLayer/src/__tests__/sourceIdentity.test.ts @@ -0,0 +1,69 @@ +// Every event this source emits carries its own identity, so downstream loop +// guards (`event.source.type !== 'dataLayer'`) can tell an echoed event from a +// captured one. Uses a real startFlow: the collector only defaults `source` +// when an event arrives without one, so a mocked push cannot prove this. +import { startFlow } from '@walkeros/collector'; +import { sourceDataLayer } from '../index'; +import type { WalkerOS } from '@walkeros/core'; + +// Web packages run under global fake timers, so settle on microtasks only. +const flush = async (): Promise => { + for (let i = 0; i < 50; i++) await Promise.resolve(); +}; + +const getDataLayer = (): unknown[] => + (window as unknown as Record)['dataLayer'] as unknown[]; + +describe('dataLayer source identity', () => { + beforeEach(() => { + Reflect.deleteProperty(window, 'dataLayer'); + }); + + test('replayed and live entries reach destinations stamped as dataLayer', async () => { + const captured: WalkerOS.Event[] = []; + + // Queued BEFORE the flow starts: replayed by processExistingEvents on run. + (window as unknown as Record)['dataLayer'] = [ + { event: 'backlog_entry' }, + ]; + + await startFlow({ + sources: { + dataLayer: { + code: sourceDataLayer, + // `env.window` is not defaulted by this source; supply it so the + // interceptor installs. + env: { window }, + }, + }, + destinations: { + cap: { + code: { + type: 'capture', + config: {}, + push: (event: WalkerOS.Event) => { + captured.push(event); + }, + }, + }, + }, + }); + await flush(); + + // Live entry through the intercepted dataLayer.push. + getDataLayer().push({ event: 'live_entry' }); + await flush(); + + const sourceOf = (name: string) => + captured.find((event) => event.name === name)?.source; + + expect(sourceOf('dataLayer backlog_entry')).toMatchObject({ + type: 'dataLayer', + platform: 'web', + }); + expect(sourceOf('dataLayer live_entry')).toMatchObject({ + type: 'dataLayer', + platform: 'web', + }); + }); +}); diff --git a/packages/web/sources/dataLayer/src/__tests__/test-utils.ts b/packages/web/sources/dataLayer/src/__tests__/test-utils.ts index 62669fdbd..b339306ed 100644 --- a/packages/web/sources/dataLayer/src/__tests__/test-utils.ts +++ b/packages/web/sources/dataLayer/src/__tests__/test-utils.ts @@ -35,9 +35,11 @@ export function createMockPush(collectedEvents: WalkerOS.Event[]) { trigger: event.trigger || '', timestamp: event.timestamp || Date.now(), timing: event.timing || 0, + // Mirrors the collector default. The source under test supplies its own + // identity, so nothing is invented here. source: { - type: event.source?.type || 'dataLayer', - platform: event.source?.platform || 'web', + type: event.source?.type ?? 'collector', + platform: event.source?.platform, }, }; collectedEvents.push(fullEvent); diff --git a/packages/web/sources/dataLayer/src/examples/step.ts b/packages/web/sources/dataLayer/src/examples/step.ts index 9cc3780a7..e46339a74 100644 --- a/packages/web/sources/dataLayer/src/examples/step.ts +++ b/packages/web/sources/dataLayer/src/examples/step.ts @@ -26,6 +26,7 @@ export const gtagPurchase: Flow.StepExample = { currency: 'EUR', items: [{ item_id: 'SKU-1', item_name: 'T-Shirt', quantity: 1 }], }, + source: { type: 'dataLayer', platform: 'web' }, }, ], ], @@ -53,6 +54,7 @@ export const consentUpdate: Flow.StepExample = { ad_storage: 'granted', analytics_storage: 'granted', }, + source: { type: 'dataLayer', platform: 'web' }, }, ], ], @@ -98,6 +100,7 @@ export const gtagAddToCart: Flow.StepExample = { }, ], }, + source: { type: 'dataLayer', platform: 'web' }, }, ], ], @@ -141,6 +144,7 @@ export const gtagViewItem: Flow.StepExample = { }, ], }, + source: { type: 'dataLayer', platform: 'web' }, }, ], ], @@ -165,6 +169,7 @@ export const directEvent: Flow.StepExample = { category: 'engagement', label: 'video_play', }, + source: { type: 'dataLayer', platform: 'web' }, }, ], ], diff --git a/packages/web/sources/dataLayer/src/interceptor.ts b/packages/web/sources/dataLayer/src/interceptor.ts index d9764e6df..1c5b5eb17 100644 --- a/packages/web/sources/dataLayer/src/interceptor.ts +++ b/packages/web/sources/dataLayer/src/interceptor.ts @@ -120,11 +120,15 @@ function processEvent( const prefix = settings.prefix || 'dataLayer'; const eventName = `${prefix} ${transformedEvent.name}`; - // Create partial WalkerOS event structure (collector will enrich it) + // Create partial WalkerOS event structure (collector will enrich it). + // The source identity is stamped here: without it the collector falls back to + // `type: 'collector'`, and destinations that echo events back into the + // dataLayer lose the `source.type !== 'dataLayer'` guard against feedback loops. const { name: _name, ...data } = transformedEvent; const partialEvent: WalkerOS.DeepPartialEvent = { name: eventName, data: data as WalkerOS.Properties, + source: { type: 'dataLayer', platform: 'web' }, }; // Push to collector diff --git a/packages/web/sources/demo/CHANGELOG.md b/packages/web/sources/demo/CHANGELOG.md index a2e652fc9..53533f987 100644 --- a/packages/web/sources/demo/CHANGELOG.md +++ b/packages/web/sources/demo/CHANGELOG.md @@ -1,5 +1,11 @@ # @walkeros/source-demo +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/demo/package.json b/packages/web/sources/demo/package.json index 0c36ff74b..e22720ee1 100644 --- a/packages/web/sources/demo/package.json +++ b/packages/web/sources/demo/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/source-demo", "description": "Demo source for walkerOS - generates events from config", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -39,10 +39,10 @@ "test": "jest" }, "dependencies": { - "@walkeros/core": "4.5.0" + "@walkeros/core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/packages/web/sources/session/CHANGELOG.md b/packages/web/sources/session/CHANGELOG.md index 2f6c6616b..7a65fec93 100644 --- a/packages/web/sources/session/CHANGELOG.md +++ b/packages/web/sources/session/CHANGELOG.md @@ -1,5 +1,12 @@ # @walkeros/web-source-session +## 4.6.0 + +### Patch Changes + +- @walkeros/core@4.6.0 +- @walkeros/web-core@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/packages/web/sources/session/package.json b/packages/web/sources/session/package.json index 2d5a7b25a..93a25222a 100644 --- a/packages/web/sources/session/package.json +++ b/packages/web/sources/session/package.json @@ -1,7 +1,7 @@ { "name": "@walkeros/web-source-session", "description": "Session source for walkerOS", - "version": "4.5.0", + "version": "4.6.0", "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -33,11 +33,11 @@ "update": "npx npm-check-updates -u && npm update" }, "dependencies": { - "@walkeros/core": "4.5.0", - "@walkeros/web-core": "4.5.0" + "@walkeros/core": "4.6.0", + "@walkeros/web-core": "4.6.0" }, "devDependencies": { - "@walkeros/collector": "4.5.0" + "@walkeros/collector": "4.6.0" }, "repository": { "url": "git+https://github.com/elbwalker/walkerOS.git", diff --git a/skills/walkeros-mcp-actions/SKILL.md b/skills/walkeros-mcp-actions/SKILL.md index 09ce99a29..21c0ee697 100644 --- a/skills/walkeros-mcp-actions/SKILL.md +++ b/skills/walkeros-mcp-actions/SKILL.md @@ -51,15 +51,17 @@ best-practice) so the call is unambiguous when multiple MCP servers are bound. ### Out of scope for this pattern -The eight cloud/auth/side-effect tools (`walkeros:auth`, +The twelve cloud/auth/side-effect tools (`walkeros:auth`, `walkeros:project_manage`, `walkeros:flow_manage`, `walkeros:deploy_manage`, -`walkeros:secret_manage`, `walkeros:feedback`, `walkeros:flow_load`, -`walkeros:flow_push`) carry authentication, cloud state, or side effects. -`walkeros:flow_push` in particular sends a **real event to real destinations** -(real API calls to live endpoints), so calling it "freely" would produce real, -duplicated sends; use `walkeros:flow_simulate` to test without sending. Do -**not** drive these tools from this code-execution filtering pattern; they -belong in an interactive, authorized session. +`walkeros:secret_manage`, `walkeros:observe_session`, +`walkeros:observe_journeys`, `walkeros:hub_manage`, `walkeros:frame_manage`, +`walkeros:feedback`, `walkeros:flow_load`, `walkeros:flow_push`) carry +authentication, cloud state, or side effects. `walkeros:flow_push` in particular +sends a **real event to real destinations** (real API calls to live endpoints), +so calling it "freely" would produce real, duplicated sends; use +`walkeros:flow_simulate` to test without sending. Do **not** drive these tools +from this code-execution filtering pattern; they belong in an interactive, +authorized session. ## The Recommended Pattern diff --git a/skills/walkeros-using-cli/commands-reference.md b/skills/walkeros-using-cli/commands-reference.md index 5ef01ec2a..85c5c236c 100644 --- a/skills/walkeros-using-cli/commands-reference.md +++ b/skills/walkeros-using-cli/commands-reference.md @@ -235,7 +235,8 @@ walkeros deploy list --type server ### Prerequisites -- **Authentication:** Set `WALKEROS_TOKEN` env var or run `walkeros auth` +- **Authentication:** Run `walkeros auth login`, or set `WALKEROS_TOKEN` to an + automation token - **Project:** Set `WALKEROS_PROJECT_ID` or use `--project` - **Flow must exist:** Create via the app UI or API first - **Server flows need an HTTP source** with `port` setting for health checks @@ -334,7 +335,8 @@ walkeros previews delete flow_abc123 prv_xyz456 --yes ### Prerequisites -- **Authentication:** `WALKEROS_TOKEN` env var or `walkeros auth` +- **Authentication:** `walkeros auth login`, or `WALKEROS_TOKEN` set to an + automation token - **Project:** `WALKEROS_PROJECT_ID` or `--project` - **Target site** must be running a walkerOS-built `walker.js` with the preview preflight baked in (all bundles from `@walkeros/cli >= 3.0` include it) @@ -419,7 +421,8 @@ Observe session ses_abc123 (live) ### Prerequisites -- **Authentication:** `WALKEROS_TOKEN` env var or `walkeros auth` +- **Authentication:** `walkeros auth login`, or `WALKEROS_TOKEN` set to an + automation token - **Project:** `WALKEROS_PROJECT_ID` or `--project` --- diff --git a/skills/walkeros-using-cli/server-deployment.md b/skills/walkeros-using-cli/server-deployment.md index a8be5f9ed..c973f28fd 100644 --- a/skills/walkeros-using-cli/server-deployment.md +++ b/skills/walkeros-using-cli/server-deployment.md @@ -7,7 +7,9 @@ collection flow. - walkerOS CLI installed: `npm install -g @walkeros/cli` - A walkerOS project (create at app.walkeros.io or via API) -- Authentication: `WALKEROS_TOKEN` and `WALKEROS_PROJECT_ID` set +- Authentication: `walkeros auth login`, or `WALKEROS_TOKEN` set to an + automation token (`wos_pat_...`) from Account, Automation tokens +- `WALKEROS_PROJECT_ID` set, or `--project` on every command ## 1. Create the Flow Config diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 253624814..b779e8dd2 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -1,5 +1,85 @@ # @walkeros/website +## 4.6.0 + +### Patch Changes + +- Updated dependencies [403ff6c] +- Updated dependencies [8802281] +- Updated dependencies [403ff6c] +- Updated dependencies [403ff6c] + - @walkeros/server-destination-aws@4.6.0 + - @walkeros/server-destination-gcp@4.6.0 + - @walkeros/collector@4.6.0 + - @walkeros/server-transformer-bot@4.6.0 + - @walkeros/web-source-datalayer@4.6.0 + - @walkeros/explorer@4.6.0 + - @walkeros/walker.js@4.6.0 + - @walkeros/server-destination-amplitude@4.6.0 + - @walkeros/server-destination-bing@4.6.0 + - @walkeros/server-destination-criteo@4.6.0 + - @walkeros/server-destination-customerio@4.6.0 + - @walkeros/server-destination-datamanager@4.6.0 + - @walkeros/server-destination-file@4.6.0 + - @walkeros/server-destination-hubspot@4.6.0 + - @walkeros/server-destination-kafka@4.6.0 + - @walkeros/server-destination-klaviyo@4.6.0 + - @walkeros/server-destination-linkedin@4.6.0 + - @walkeros/server-destination-meta@4.6.0 + - @walkeros/server-destination-mixpanel@4.6.0 + - @walkeros/server-destination-mparticle@4.6.0 + - @walkeros/server-destination-pinterest@4.6.0 + - @walkeros/server-destination-posthog@4.6.0 + - @walkeros/server-destination-reddit@4.6.0 + - @walkeros/server-destination-redis@4.6.0 + - @walkeros/server-destination-rudderstack@4.6.0 + - @walkeros/server-destination-segment@4.6.0 + - @walkeros/server-destination-slack@4.6.0 + - @walkeros/server-destination-snapchat@4.6.0 + - @walkeros/server-destination-sqlite@4.6.0 + - @walkeros/server-destination-tiktok@4.6.0 + - @walkeros/server-destination-twitter@4.6.0 + - @walkeros/server-source-aws@4.6.0 + - @walkeros/server-source-express@4.6.0 + - @walkeros/server-source-fetch@4.6.0 + - @walkeros/server-source-gcp@4.6.0 + - @walkeros/web-destination-amplitude@4.6.0 + - @walkeros/web-destination-api@4.6.0 + - @walkeros/web-destination-clarity@4.6.0 + - @walkeros/web-destination-d8a@4.6.0 + - @walkeros/web-destination-fullstory@4.6.0 + - @walkeros/web-destination-heap@4.6.0 + - @walkeros/web-destination-hotjar@4.6.0 + - @walkeros/web-destination-linkedin@4.6.0 + - @walkeros/web-destination-matomo@4.6.0 + - @walkeros/web-destination-meta@4.6.0 + - @walkeros/web-destination-mixpanel@4.6.0 + - @walkeros/web-destination-optimizely@4.6.0 + - @walkeros/web-destination-piano@4.6.0 + - @walkeros/web-destination-pinterest@4.6.0 + - @walkeros/web-destination-piwikpro@4.6.0 + - @walkeros/web-destination-plausible@4.6.0 + - @walkeros/web-destination-posthog@4.6.0 + - @walkeros/web-destination-segment@4.6.0 + - @walkeros/web-destination-snowplow@4.6.0 + - @walkeros/web-destination-tiktok@4.6.0 + - @walkeros/web-source-browser@4.6.0 + - @walkeros/web-source-cmp-cookiefirst@4.6.0 + - @walkeros/web-source-cmp-cookiepro@4.6.0 + - @walkeros/web-source-cmp-usercentrics@4.6.0 + - @walkeros/web-source-session@4.6.0 + - @walkeros/core@4.6.0 + - @walkeros/server-destination-api@4.6.0 + - @walkeros/server-store-fs@4.6.0 + - @walkeros/server-store-gcs@4.6.0 + - @walkeros/server-store-s3@4.6.0 + - @walkeros/server-store-sheets@4.6.0 + - @walkeros/server-transformer-file@4.6.0 + - @walkeros/server-transformer-fingerprint@4.6.0 + - @walkeros/transformer-ga4@4.6.0 + - @walkeros/transformer-validate@4.6.0 + - @walkeros/web-destination-gtag@4.6.0 + ## 4.5.0 ### Patch Changes diff --git a/website/docs/apps/cli.mdx b/website/docs/apps/cli.mdx index c1649154c..66dc33d5f 100644 --- a/website/docs/apps/cli.mdx +++ b/website/docs/apps/cli.mdx @@ -1091,7 +1091,7 @@ The `auth` command group manages authentication with the walkerOS cloud service. ### Login -Log in to walkerOS via an OAuth browser flow. The CLI requests a device code, opens your browser for authorization, and polls for the resulting token. +Log in to walkerOS through the RFC 8628 device authorization grant. The CLI asks the app for a one-time code, opens your browser on the approval page, and polls until you approve. No password reaches the CLI and no local callback server is opened, so this works the same over SSH, inside Docker and in a cloud IDE. diff --git a/website/docs/apps/mcp.mdx b/website/docs/apps/mcp.mdx index 0ef0119c1..03f3524e4 100644 --- a/website/docs/apps/mcp.mdx +++ b/website/docs/apps/mcp.mdx @@ -72,13 +72,15 @@ Everything in this loop runs locally, no account: | Variable | Used by | Required | Default | Purpose | |----------|---------|----------|---------|---------| -| `WALKEROS_TOKEN` | mcp | No | none | Bearer token fallback (alternative to `auth` tool login) | +| `WALKEROS_TOKEN` | mcp | No | none | An automation token (`wos_pat_...`) from Account, Automation tokens. An alternative to the `auth` tool login, for a machine with no browser | | `WALKEROS_PROJECT_ID` | mcp | No | none | Active project ID (`proj_...`) | | `WALKEROS_APP_URL` | mcp | No | `https://app.walkeros.io` | Base URL override | `@walkeros/mcp-source-browser` works without any environment variables. All tools are always registered. To authenticate with the walkerOS cloud, use -the `auth` tool (device code flow) or set `WALKEROS_TOKEN` as a fallback. +the `auth` tool, which runs the device authorization grant and holds a session +that refreshes itself, or set `WALKEROS_TOKEN` to an automation token when +nobody is there to approve anything. --- @@ -123,9 +125,9 @@ export const POST = handler;`} /> To use the raw tool registry without the MCP protocol (e.g., with the Vercel -AI SDK), import \`TOOL_DEFINITIONS\` and provide your own \`ToolClient\` -implementation. The stdio binary stays available via \`@walkeros/mcp/stdio\` -and the \`walkeros-mcp\` bin entry, unchanged for end users. +AI SDK), import `TOOL_DEFINITIONS` and provide your own `ToolClient` +implementation. The stdio binary stays available via `@walkeros/mcp/stdio` +and the `walkeros-mcp` bin entry. ### Tools @@ -274,7 +276,7 @@ example summaries. Use `section` parameter for full content. Report the MCP runtime surface. Read-only, takes no parameters, and works even when logged out. Reach for it when a request fails, to see which versions and backend you are on. The response includes the MCP version, the CLI version, the -resolved app URL and whether it came from `WALKEROS_APP_URL` or the default, app +app URL the client resolved and whether `WALKEROS_APP_URL` is what set it, app `/api/health` reachability, the bundled OpenAPI contract version, and which source served the last package catalog lookup. @@ -288,8 +290,16 @@ These tools manage flows in the hosted app: shared projects, deploys, secrets, a ##### `auth` -Authenticate with the walkerOS cloud. Uses device code flow — the user receives -a URL to open in a browser to complete login. +Authenticate with the walkerOS cloud through the RFC 8628 device authorization +grant. No terminal and no callback server: the tool answers with a URL, the +person approves it in a browser they are already signed in to, and a second call +with the same `deviceCode` resumes polling until the approval lands. The session +that results refreshes itself, and it shows up under Account, Connected apps, +where disconnecting it takes effect on the next call. + +On the hosted door there is nothing to log in to, because every request carries +its own bearer: `auth` reports `authenticated: false` there and +`action: "login"` fails. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -460,6 +470,53 @@ When the flow has no active Observe session the result is `{ "sessionId": null, "journeys": [], "gaps": [] }`. Start an Observe session and drive traffic first, then read again. +##### `hub_manage` + +Read a flow's release history and the reasoning behind it: what each release +changed, and why. `release_get` returns a diff the server computes against the +release before it, and a diff is never accepted from a caller. Writes are +additive: `rationale_set` records why a release happened, `note_add` appends to a +discussion, and there is no delete. Threads are resolved by a person in the app, +never here. Steps are addressed as `type.name`, the same form `flow_simulate` +takes. Needs the `hub` feature. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | `"releases"` \| `"release_get"` \| `"step_history"` \| `"rationale_set"` \| `"threads"` \| `"note_add"` \| `"knowledge"` | Yes | Which part of the release history to read or write | +| `projectId` | string | No | Project ID (`proj_...`). Optional: falls back to the default project when omitted. | +| `flowId` | string | No | Flow ID (`flow_...`). Required for every action except `knowledge`, which hangs on a page rather than a flow and refuses this field. | +| `versionId` | string | No | Release version ID (`ver_...`) from action `releases`. Addresses one release for `release_get` and `rationale_set`. Pass this or `versionNumber`. | +| `versionNumber` | number | No | Spine release number for this flow, the `versionNumber` field of action `releases`. Alternative to `versionId`. Not the same as a row's `deploymentAttempt`. | +| `step` | string | No | Step key as `type.name`, e.g. `destination.ga4` or `contract.checkout`. Required for `step_history`. | +| `flow` | string | No | Named flow inside the config, e.g. `web` or `server`. Optional for `step_history`: omit to scan every named flow. Ignored for contract steps, which are top-level. | +| `text` | string | No | The text to write (1-4000 chars). Required for `rationale_set` and `note_add`. As rationale it replaces the note already on the release and never touches the machine summary. As a note it is appended to a thread and nothing is ever replaced. | +| `limit` | number | No | Page size. Releases to list for action `releases` (max 100), or releases to scan for `step_history` (max 50). For `step_history` this bounds releases, not entries: a step present in several named flows yields one entry per flow per release, and the scan stops at 200 entries with `entriesTruncated` set. Narrow with `flow` to avoid that. For `threads` it bounds threads, and a read that carries messages is held to 20 of them. Knowledge entries are bounded the same way. | +| `offset` | number | No | Releases to skip. Action `releases` only. | +| `anchorType` | `"step"` \| `"entity_action"` \| `"release"` \| `"contract"` \| `"tag"` | No | What a thread hangs on. Defaults to `release`, the only anchor the app writes today. Pair it with `anchorKey`, since a key means a different thing under each type. | +| `anchorKey` | string | No | What the anchor addresses within its type: a release version ID (`ver_...`) for `release`, a `type.name` step key for `step`. For a release you can pass `versionId` or `versionNumber` instead. Omit entirely on action `threads` to read every thread on the flow. | +| `anchorLabel` | string | No | How the anchor reads on screen, stored once when a thread is opened so a later rename leaves it readable. Derived for a release (`v14`). Pass it only when opening a thread on another anchor type. | +| `threadId` | string | No | Thread ID (`thr_...`) from action `threads`. Pass it to `note_add` to reply in that thread, omit it to open a new thread on the anchor. | +| `status` | `"open"` \| `"resolved"` | No | Read only threads in this state. Action `threads` only, omit for both. | +| `pageKey` | string | No | The page a note was left on, as Tag Mode addressed it, usually the page URL. Narrows action `knowledge` to every frame that page holds, at any depth. Omit it to read the whole project. | +| `frameId` | string | No | One frame (`frm_...`), the named rectangle a note hangs on. Action `knowledge` only. Narrower than `pageKey`, since a page holds several frames. | +| `markId` | string | No | One mark within `frameId`. Action `knowledge` only, and refused without `frameId`, since a mark id alone addresses nothing. Naming a mark is also what attaches the message bodies. | + +##### `frame_manage` + +Read the frames of a measurement plan: named rectangles with marks inside them, +drawn in Tag Mode or in the app. A frame name is documentation, the marks inside +it carry the meaning. Read-only, because a frame's geometry only means something +next to the pixels it was drawn on, so frames are edited where the page is. +Use `hub_manage` with action `knowledge` to read what people wrote on a frame. +Needs the `frames` feature. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | `"list"` \| `"page"` \| `"get"` | Yes | `list` returns every frame of the project without marks, `page` returns one page's frames with their marks, `get` returns one frame with its marks. | +| `projectId` | string | No | Project ID (`proj_...`). Optional: falls back to the default project when omitted. | +| `pageKey` | string | No | The page as its frames address it (the `source.key` of a page frame, usually the page URL without query). Required for `page`. | +| `frameId` | string | No | Frame ID (`frm_...`). Required for `get`. Use action `list` or `page` to find one. | + ### Resources | URI | Description | @@ -482,6 +539,162 @@ drive traffic first, then read again. | `setup-mapping` | Configure event mapping for a step | | `manage-contract` | Create/update event contracts (bidirectional with mappings) | +### Links back into the app + +Cloud tools that answer about something you can look at return a link to that +screen, so the answer and the screen arrive together instead of leaving you to +go find it. + +| Tool | Actions that return a link | Screen | +|------|----------------------------|--------| +| `flow_manage` | `get`, `create` | the flow page | +| `deploy_manage` | `deploy`, `get` | the deployment's detail page | +| `hub_manage` | `releases`, `step_history`, `threads` | the flow's release history, or the step | + +The link is a full URL in the response field **`appUrl`**. It is `appUrl` and +never `url`, because a deployment response already carries `url` and that means +where the deployment is serving. + +```json +{ + "releases": [{ "versionId": "ver_...", "versionNumber": 7, "status": "active" }], + "total": 12, + "appUrl": "https://app.walkeros.io/projects/proj_x/flows/flow_y?view=releases" +} +``` + +The address is the app's own: `/projects/{projectId}/flows/{flowId}` for a +flow, `/projects/{projectId}/deployments/{deploymentId}` for a deployment, and +a `view` query param for a screen on the flow page (`releases`, `contract`, +`step`, and the rest). A step is `?view=step&flow=&step=`, +the same `flow` and `step` vocabulary `hub_manage` takes as parameters. + +Two things worth knowing: + +- **The link points at the app the door is connected to.** The hosted door + answers with its own origin; the local door answers with whatever + `WALKEROS_APP_URL` or your CLI config resolves to. Run `diagnostics` if you + are unsure which backend you are on. +- **No link is better than a wrong one.** When a tool cannot name a screen that + exists, the response simply carries no `appUrl`. A step scan that named no + flow, a step whose last change was its removal, and a thread anchored to + anything but a release all fall in that bucket. + +## Two doors, one tool set + +Every `@walkeros/mcp` tool is registered on both doors, and both doors run the +same code against the same app. + +| Door | How it runs | Auth | +|------|-------------|------| +| Local | `npx @walkeros/mcp` over stdio, or the Claude Code plugin. The local tools run on your own machine, the cloud tools call the app over HTTPS. | `auth` tool login, or `WALKEROS_TOKEN` | +| Hosted | `POST https://app.walkeros.io/api/mcp` over Streamable HTTP. Tools run inside the app. | OAuth, discovered from the endpoint itself | + +Three things behave differently. + +**File paths.** `flow_load`, `flow_validate`, `flow_bundle`, `flow_simulate`, +`flow_push`, and `flow_examples` accept a file path, and a path names the +filesystem of the machine running the door. On the local door that is your +machine. On the hosted door it is the app server, which cannot read your files. +Give the hosted door inline JSON or a flow ID instead of a path. + +**Login.** `auth` runs the device authorization grant on the local door. The +hosted door authenticates every request with its own bearer instead, so there is +nothing to log in to: `auth` reports `authenticated: false` there and +`action: "login"` fails. Skip the tool on that door. + +**The selected project.** `project_manage` action `set_default` is remembered +differently by each door. The local door writes it to your CLI config file, so it +survives restarts. The hosted door holds it only for as long as the connection +lasts, and a reconnect starts with no project selected. Passing `projectId` on +the call always works and is the form to prefer on the hosted door. A call with +no project names both remedies in its error, so an assistant can recover without +guessing. + +## Connect Claude Desktop, claude.ai or Cursor + +The hosted door is one HTTPS endpoint: `https://app.walkeros.io/api/mcp`. It is +the only value a client needs. No token, no header, no advanced field. + +The endpoint runs an OAuth 2.1 authorization server, and a standard MCP client +finds it on its own. You paste the URL, the client discovers who authorizes it +and registers itself, a browser opens on a consent screen, you press Allow, and +the client holds a token it refreshes without ever asking again. + +### Claude Desktop and claude.ai + +1. Settings, then Connectors. +2. Add custom connector. +3. URL: `https://app.walkeros.io/api/mcp`. Leave every advanced setting alone. +4. The consent screen opens. Allow. +5. The walkerOS tools appear in the client. + +### Claude Code + + + +Then run `/mcp` inside Claude Code and choose to sign in. A browser opens on the +consent screen, Allow, and the browser hands back to Claude Code on a local +port. `/mcp` then lists the walkerOS tools. + +### Cursor and other MCP clients + +Any client that speaks Streamable HTTP MCP takes the same URL, +`https://app.walkeros.io/api/mcp`, in its MCP server settings. Everything else +is discovered. + +### What the consent screen asks for + +| Permission | What it means | +|---|---| +| Read your projects and flows | List and read projects, flows, deployments, settings and observation data. | +| Change and deploy flows | Create, edit, deploy and delete flows, and manage project secrets and previews. | +| Stay connected | Keep working without signing in again, until you disconnect it. | + +Consent is per person, once, and it applies to every project that person belongs +to. Which project a given action touches stays a choice made in the +conversation, not at connect time. + +If you are not signed in to walkerOS in that browser, the magic-link login runs +first and hands back to the consent screen afterwards. + +### Disconnecting + +Account, then Connected apps, lists every connected client with the permissions +it was given and when it last called. Disconnect takes effect on that app's very +next request, and it can be connected again at any time. + +### A credential for a machine + +A script, a CI job or a self-hosted MCP server has nobody to press Allow. Mint +an automation token instead, under Account, then Automation tokens: pick read or +read and write, pick a lifetime, and copy the `wos_pat_...` value once. Pass it +as `WALKEROS_TOKEN`, or as `Authorization: Bearer` against either door. + +### What your plan has to include + +A connected client acts as you: it reads and changes every project you are a +member of, within what your plan enables. The endpoint needs the `mcp` feature, +`hub_manage` needs `hub`, and `frame_manage` needs `frames`. A call to a feature +your plan does not include answers `FEATURE_NOT_AVAILABLE` and names the +feature. Connecting before the plan includes MCP is fine; the consent screen +says so, and no reconnect is needed after an upgrade. + +### If something goes wrong + +| Symptom | Cause | Fix | +|---|---|---| +| The client says it cannot authenticate | The URL is wrong, or points at an environment that is down | Check it is exactly `https://app.walkeros.io/api/mcp` | +| The browser opens on the login page instead of the consent screen | Not signed in to walkerOS in that browser | Sign in; the consent screen follows on its own | +| Tools are listed but every call is refused | The plan does not include the `mcp` feature | Upgrade the project's plan | +| The app suddenly asks to reconnect | It was disconnected under Account, Connected apps | Reconnect it, or leave it disconnected | + +Against stage, substitute `https://stage.app.walkeros.io/api/mcp`; against a +local app, `http://localhost:3000/api/mcp`. + --- ## @walkeros/mcp-source-browser (HTML tagging tools) diff --git a/website/docs/apps/runner.mdx b/website/docs/apps/runner.mdx index 823114936..d084816c6 100644 --- a/website/docs/apps/runner.mdx +++ b/website/docs/apps/runner.mdx @@ -50,12 +50,17 @@ No signup, no token, no API: ### Mode B: local config + dashboard -Adds heartbeat registration. The runner appears in your project dashboard: +Adds heartbeat registration. The runner appears in your project dashboard. + +`WALKEROS_TOKEN` is an **automation token**: mint one in the app under Account, +then Automation tokens, and copy the `wos_pat_...` value. A flow deployed from +the app's self-hosted tab gets its own bound runner token (`wos_run_...`) +printed straight into the `docker run` snippet there; either kind works here.