diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ce1629..627f140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: cache: npm - name: Install the committed npm dependency graph run: npm ci --ignore-scripts --no-audit --no-fund - - name: Check all four 0.38 adaptors, event services and compiled jobs + - name: Check the 0.45 adaptors, event services and compiled jobs run: npm run check - name: Check adaptor package contents run: npm run pack:dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index f371bac..8582243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,29 +8,41 @@ Its existing `0.0.0.0` default remains unchanged. - `@openfn/language-registry-casework` wraps the native Casework client for requester review requests and results, source inboxes and task grants. -- `@openfn/language-registry-relay` provides governed V2 record reads, lookups, - pagination, metadata and SDMX aggregate queries through the native client. ### Changed -- The Evidence, BREG, Casework and Relay V2 adaptors pin the published - `@registrystack/client` 0.38.0. Relay uses governed V2 resources and native - continuations; Casework uses current review-request bindings and results. -- The container pilot uses matching Registry Stack 0.38 clients, tools and - packages, ThunderID identity, Casework correction review, and explicit BREG - application. Evidence uses persistent OpenBao Transit signing and local TLS - origins. Its new configuration and volumes are separate from the retained - 0.27 data. +- The Evidence, BREG and Casework adaptors pin the published + `@registrystack/client` 0.45.0. Configuration uses `attemptTimeoutMilliseconds` + and `maximumResponseBytes`; the client's error kinds are kebab-case, and the + adaptors' safe validation reasons follow them. +- Casework uses the review task routes: `listCaseworkReviewTasks`, + `getCaseworkReviewTask`, `previewCaseworkTaskTemplates`, + `listCaseworkTaskGrants`, `approveCaseworkTaskGrant`, + `revokeCaseworkTaskGrant` and `caseworkTaskGrantStatus`. Inputs name a + `taskId` instead of an `itemId`. +- BREG reads record types (`recordTypes`, `recordTypeIdentifier`); `getEntitySchema` + is now `getRecordSchema` and takes `recordTypeIdentifier`. Failures report + `refusalCode` and `reasonCode` in place of `planRefusal`. +- Evidence profile files use the `EvidenceClientProfile` envelope, expected + output forms are mappings such as `{ type: "boolean" }`, and the progressive + request names its requirement as `handle`. - The event bridge's explicit `hook-envelope-v1` mode validates canonical 0.38 events and returns the native notification acknowledgement after durable OpenFn acceptance. The legacy body mode remains available. ### Removed -- Removed the Relay v1 adaptor (`@openfn/language-registry-relay`), its - credential schema, example job, tests and workspace dependencies. Workflows - using that local adaptor must be updated before upgrading. The replacement - native Relay V2 adaptor uses different configuration, options and result shapes. +- Removed the Relay adaptor (`@openfn/language-registry-relay`): the 0.45 client + has no Relay namespace. +- Removed the Casework adaptor operations `listCaseworkWorkItems` and + `getCaseworkWorkItem`; the review task operations replace them. +- Removed the BREG operations `createChangeRequest` and `executeLifecycleAction`, + and the `jobs/create-and-submit.js` example. The 0.45 client replaces the + change-request lifecycle with metadata-declared Actions and Proposals, which + this adaptor does not wrap yet. +- Removed the `correction` workflow from the generated pilot Lightning project, + which depended on those BREG operations. The container pilot itself is still + pinned to Registry Stack 0.38.0 server images and has not been repinned. ## 0.1.0 Beta diff --git a/README.md b/README.md index e13fe04..cabc06d 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,19 @@ **Beta:** intended for supervised, self-hosted pilots. APIs and deployment configuration may change before 1.0. -The BREG, Evidence, Relay V2 and Casework adaptors target the published -`@registrystack/client@0.38.0`. Use matching 0.38 runtimes and tools for new -integrations. Run `npm run check` to verify all four adaptors and their OpenFn -composition. Relay and Casework now use their current native APIs; consult their -package READMEs when migrating older jobs. - -The container pilot uses matching **Registry Stack 0.38.0** clients, runtimes, -tools and authored packages, ThunderID for identity, and Casework for correction -review. It creates a fresh deployment with separate databases and private -configuration. The previous 0.27 pilot's `pilot/agriculture/.runtime` directory -and `registry-openfn-pilot` volumes are retained and are not migrated or reused. +The BREG, Evidence and Casework adaptors target the published +`@registrystack/client@0.45.0`. Use matching 0.45 runtimes and tools for new +integrations. Run `npm run check` to verify the three adaptors and their OpenFn +composition. The Relay adaptor is removed: the 0.45 client has no Relay +namespace. + +**Pilot status:** the container pilot under `deployment/` and `pilot/agriculture/` +still pins Registry Stack 0.38.0 server images, tools and checksums, and has not +been repinned or run against 0.45. The generated Lightning project no longer +contains the correction workflow, because it depended on the BREG change-request +lifecycle operations that the 0.45 client removed. The pilot smoke test and the +correction sections below describe the 0.38 flow and need a 0.45 pilot +registry before they apply again. A self-hosted synthetic pilot connecting upstream OpenFn Lightning to Registry Stack Base Registry Engine (BREG) and signed Evidence. It demonstrates registration, @@ -24,9 +26,9 @@ an isolated local pilot. The pilot uses: - [Registry Evidence adaptor](packages/registry-evidence), using the `evidence` - namespace of the published `@registrystack/client@0.38.0`. + namespace of the published `@registrystack/client@0.45.0`. - [Registry BREG adaptor](packages/registry-breg), using the same package's `breg` - namespace for metadata-selected writes, exact lookups and lifecycle actions. + namespace for metadata-selected writes and exact lookups. - Upstream Lightning 2.18.2 and websocket worker 1.29.0, packaged with Node 24.19.0 on glibc and local adaptors. No OpenFn source patch is required. - Separate BREG, Casework and Lightning PostgreSQL databases, ThunderID, diff --git a/deployment/check-worker.mjs b/deployment/check-worker.mjs index 7107505..e87399f 100644 --- a/deployment/check-worker.mjs +++ b/deployment/check-worker.mjs @@ -45,7 +45,6 @@ async function executeJob(job, input) { const document = projectDocument({ accessProfile: 'openfn-service', createFarmOperation: 'records.farm.create', - createCorrectionOperation: 'records.name-correction.create', evidenceRequest: { responseFormat: 'signed-jws', requirement: 'urn:example:requirement:holding-registered:v1', @@ -65,7 +64,7 @@ const document = projectDocument({ handle: 'registered', concept: 'urn:example:concept:holding-registered:registered', required: true, - form: 'boolean', + form: {type: 'boolean'}, }], maximumAssertionLifetimeSeconds: 300, clockSkewSeconds: 30, diff --git a/deployment/package-lock.json b/deployment/package-lock.json index 15837bb..514a8e7 100644 --- a/deployment/package-lock.json +++ b/deployment/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@openfn/language-common": "3.3.4", "@openfn/language-http": "7.3.3", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "engines": { "node": ">=22.12.0" @@ -93,23 +93,23 @@ } }, "node_modules/@registrystack/client": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", - "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.45.0.tgz", + "integrity": "sha512-QxXcn8GPzhngYY9zFwrymqprnO/qeuPqbByKXGRHc8Bg9oFwo5PghFoTzdHqaQkq0h3ZDsEFUgPycEicKTRkCg==", "license": "Apache-2.0", "engines": { - "node": ">=22.12.0" + "node": ">=24.11.0" }, "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.38.0", - "@registrystack/client-linux-arm64-gnu": "0.38.0", - "@registrystack/client-linux-x64-gnu": "0.38.0" + "@registrystack/client-darwin-arm64": "0.45.0", + "@registrystack/client-linux-arm64-gnu": "0.45.0", + "@registrystack/client-linux-x64-gnu": "0.45.0" } }, "node_modules/@registrystack/client-darwin-arm64": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", - "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.45.0.tgz", + "integrity": "sha512-J/kg+NedFTkgYgvFUgiJS0/Sefpl3K9nlVRlSmYrqrIKe5PfO3Suw28U/yH126CUmW/TCy9k3He0ydvZB+5i/g==", "cpu": [ "arm64" ], @@ -119,39 +119,45 @@ "darwin" ], "engines": { - "node": ">=22.12.0" + "node": ">=24.11.0" } }, "node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", - "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.45.0.tgz", + "integrity": "sha512-V/99SANNmNxdxPUukPlPbxxIzWL7VTcu2b8vKZcsxGvyAgSxZhs1qGWRWLuuuFtkN7uj147ItZ85CqG58twoEg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=22.12.0" + "node": ">=24.11.0" } }, "node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", - "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.45.0.tgz", + "integrity": "sha512-sDc6hSnivz0A+KfXce9OHEOmNIiAZRp/fBUVl1fW7azbI5zfN52aLRgZmA85m4QGeJNX6t7uC/iCHjsHjwgxqw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=22.12.0" + "node": ">=24.11.0" } }, "node_modules/ajv": { diff --git a/deployment/package.json b/deployment/package.json index 95d87a8..a23bb84 100644 --- a/deployment/package.json +++ b/deployment/package.json @@ -6,7 +6,7 @@ "dependencies": { "@openfn/language-common": "3.3.4", "@openfn/language-http": "7.3.3", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "overrides": { "csv-parse": "7.0.2" diff --git a/package-lock.json b/package-lock.json index caa6bcf..83018a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -325,10 +325,6 @@ "resolved": "packages/registry-evidence", "link": true }, - "node_modules/@openfn/language-registry-relay": { - "resolved": "packages/registry-relay", - "link": true - }, "node_modules/@openfn/lexicon": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@openfn/lexicon/-/lexicon-2.4.0.tgz", @@ -370,6 +366,74 @@ "source-map": "^0.7.6" } }, + "node_modules/@registrystack/client": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.45.0.tgz", + "integrity": "sha512-QxXcn8GPzhngYY9zFwrymqprnO/qeuPqbByKXGRHc8Bg9oFwo5PghFoTzdHqaQkq0h3ZDsEFUgPycEicKTRkCg==", + "license": "Apache-2.0", + "engines": { + "node": ">=24.11.0" + }, + "optionalDependencies": { + "@registrystack/client-darwin-arm64": "0.45.0", + "@registrystack/client-linux-arm64-gnu": "0.45.0", + "@registrystack/client-linux-x64-gnu": "0.45.0" + } + }, + "node_modules/@registrystack/client-darwin-arm64": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.45.0.tgz", + "integrity": "sha512-J/kg+NedFTkgYgvFUgiJS0/Sefpl3K9nlVRlSmYrqrIKe5PfO3Suw28U/yH126CUmW/TCy9k3He0ydvZB+5i/g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=24.11.0" + } + }, + "node_modules/@registrystack/client-linux-arm64-gnu": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.45.0.tgz", + "integrity": "sha512-V/99SANNmNxdxPUukPlPbxxIzWL7VTcu2b8vKZcsxGvyAgSxZhs1qGWRWLuuuFtkN7uj147ItZ85CqG58twoEg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=24.11.0" + } + }, + "node_modules/@registrystack/client-linux-x64-gnu": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.45.0.tgz", + "integrity": "sha512-sDc6hSnivz0A+KfXce9OHEOmNIiAZRp/fBUVl1fW7azbI5zfN52aLRgZmA85m4QGeJNX6t7uC/iCHjsHjwgxqw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=24.11.0" + } + }, "node_modules/@types/mute-stream": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.1.tgz", @@ -2877,7 +2941,7 @@ "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2887,75 +2951,13 @@ "node": ">=22.12.0" } }, - "packages/registry-breg/node_modules/@registrystack/client": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", - "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.38.0", - "@registrystack/client-linux-arm64-gnu": "0.38.0", - "@registrystack/client-linux-x64-gnu": "0.38.0" - } - }, - "packages/registry-breg/node_modules/@registrystack/client-darwin-arm64": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", - "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-breg/node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", - "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-breg/node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", - "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "packages/registry-casework": { "name": "@openfn/language-registry-casework", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2965,153 +2967,13 @@ "node": ">=22.12.0" } }, - "packages/registry-casework/node_modules/@registrystack/client": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", - "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.38.0", - "@registrystack/client-linux-arm64-gnu": "0.38.0", - "@registrystack/client-linux-x64-gnu": "0.38.0" - } - }, - "packages/registry-casework/node_modules/@registrystack/client-darwin-arm64": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", - "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-casework/node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", - "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-casework/node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", - "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "packages/registry-evidence": { "name": "@openfn/language-registry-evidence", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" - }, - "devDependencies": { - "@openfn/compiler": "1.3.0", - "@openfn/runtime": "1.9.5" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-evidence/node_modules/@registrystack/client": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", - "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.38.0", - "@registrystack/client-linux-arm64-gnu": "0.38.0", - "@registrystack/client-linux-x64-gnu": "0.38.0" - } - }, - "packages/registry-evidence/node_modules/@registrystack/client-darwin-arm64": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", - "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-evidence/node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", - "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-evidence/node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", - "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-relay": { - "name": "@openfn/language-registry-relay", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -3120,68 +2982,6 @@ "engines": { "node": ">=22.12.0" } - }, - "packages/registry-relay/node_modules/@registrystack/client": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.38.0.tgz", - "integrity": "sha512-hDOUUuSF3IDzaUzb+Tf84GhQpmJ/ODNlE9pegNBgReniLNk+V9/z4wegb/lGKZp3zu7ZmTj5xgXNCETdj3nH/w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.38.0", - "@registrystack/client-linux-arm64-gnu": "0.38.0", - "@registrystack/client-linux-x64-gnu": "0.38.0" - } - }, - "packages/registry-relay/node_modules/@registrystack/client-darwin-arm64": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.38.0.tgz", - "integrity": "sha512-mTn08j4I7Ti6dBbvX8gZ0dzK1s7Cbkuo9cmdTzhE0haoRUe0hrUGn3E+crHqHo9PCyyo+vPt/IqcIrR2+SInnw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-relay/node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.38.0.tgz", - "integrity": "sha512-8nhH2fAXhhqFVAaCWUytF+Qykqid4MQ/aOaYZGCsfMjI31y3aACxlHvrG9iuOYY61oUj6SBbty5l13BW/+0/IQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "packages/registry-relay/node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.38.0.tgz", - "integrity": "sha512-YqVjiaQIWfxBmx+fkovjY0+lhtaj7nZTYR6fPl6kW1tw45jlKzAmrJ77Ni25tcyHbYIEWpVYxwCm67UsOUHDUQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } } } } diff --git a/package.json b/package.json index 9fd8aed..0e8d75d 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,10 @@ "packages/*" ], "scripts": { - "check": "npm run check:evidence && npm run check:breg && npm run check:casework && npm run check:relay && npm run check:events && npm run check:pilot", + "check": "npm run check:evidence && npm run check:breg && npm run check:casework && npm run check:events && npm run check:pilot", "check:evidence": "npm run check --workspace @openfn/language-registry-evidence", "check:breg": "npm run check --workspace @openfn/language-registry-breg", "check:casework": "npm run check --workspace @openfn/language-registry-casework", - "check:relay": "npm run check --workspace @openfn/language-registry-relay", "check:events": "node --test services/event-bridge/test/*.test.js services/destination/test/*.test.js", "check:pilot": "node --experimental-vm-modules --no-warnings --test pilot/test/*.test.js && python3 -m unittest discover -s pilot/test -p \"*_test.py\"", "test": "npm run check", diff --git a/packages/registry-breg/README.md b/packages/registry-breg/README.md index 087bfd6..1ca97b8 100644 --- a/packages/registry-breg/README.md +++ b/packages/registry-breg/README.md @@ -1,7 +1,7 @@ # @openfn/language-registry-breg OpenFn operations over the native `breg` namespace in the exact published -`@registrystack/client@0.38.0`. The adaptor uses +`@registrystack/client@0.45.0`. The adaptor uses `@openfn/language-common@3.3.4` and reexports its composition helpers. It does not implement Registry authorization or an alternative HTTP client. @@ -15,15 +15,15 @@ Put the native SDK configuration in `state.configuration.breg`, or directly in "breg": { "baseUrl": "https://registry.example.invalid/", "authorization": { "static": "REPLACE_WITH_BEARER_TOKEN" }, - "requestTimeoutMilliseconds": 30000, + "attemptTimeoutMilliseconds": 30000, "maximumResponseBytes": 4194304 } } ``` -The adaptor maps `maximumResponseBytes` to the pinned 0.38 client's internal -`maxResponseBytes` field. The authored `breg` namespace is closed, so the old -spelling and other unknown fields are refused by the configuration contract. +The authored `breg` namespace is closed, so retired spellings such as +`maxResponseBytes` and `requestTimeoutMilliseconds`, and other unknown fields, +are refused by the configuration contract. Alternatively use `authorization: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, audience } }`, where `clientKey` is a private JWK with `kty`, `kid`, @@ -45,8 +45,7 @@ Results appear at `state.data[as]`, defaulting to `state.data.breg`: ``` Optional fields appear only when the SDK returned them. A read returns a Registry -Record envelope at `value`, lists return a collection, and lifecycle actions -return receipts. These remain different contracts. Successful metadata discovery +Record envelope at `value`, and lists return a collection. These remain different contracts. Successful metadata discovery returns descriptive JSON, never opaque SDK authority handles. Configuration remains present between operations so `execute`, `each`, and @@ -61,52 +60,39 @@ when the next step no longer needs them. The adaptor does not log requests. | Operation | Required inputs and behavior | | --- | --- | | `discoverRegistry` | Optional `accessProfile`; caller-filtered metadata. | -| `getEntitySchema` | `entityIdentifier`, optional `accessProfile`. | +| `getRecordSchema` | `recordTypeIdentifier`, optional `accessProfile`. | | `getRecord` | `route`, `recordIdentifier`. | | `lookupRecord` | `route`, `selector`; `values` for request selectors, omitted for verified-claim selectors. Exact lookup, no list scan fallback. | | `listRecords` | `route`; optional `top`, `filter`, `orderby`, `count`. One page only. | | `continueList` | Exact SDK `continuation` from the previous page. | | `createRecord` | `operationIdentifier`, `accessProfile`, `data`, `idempotencyKey`. | | `patchRecord` | `operationIdentifier`, `accessProfile`, `recordIdentifier`, `etag`, `operations`, `idempotencyKey`. | -| `createChangeRequest` | Same as create; use the configured change-request entity's create operation to create a draft. | -| `executeLifecycleAction` | `entityIdentifier`, `route`, `recordIdentifier`, `accessProfile`, `operation`, action `ifMatch`, `idempotencyKey`. | Reads and lookup accept `select`, `accessProfile`, and `format` (`json` or -`json-ld`). Lists also accept these. Creates, patches and lifecycle actions accept -`format`. Use the authored entity route, operation ID and field API names exactly +`json-ld`). Lists also accept these. Creates and patches accept +`format`. Use the authored record-type route, operation ID and field API names exactly as published in metadata. Patch members use `{ op: 'replace', field: 'legalName', value: '...' }`, not raw HTTP JSON Pointer paths. The native client constructs and validates the HTTP patch document. Writes fetch caller-filtered metadata inside the operation and select an opaque binding for the exact operation and profile. No function accepts a write URL, -serialized binding, or arbitrary action URL as authority. Immediate-action -metadata remains descriptive; this adaptor does not execute it. - -Use the direct read's exact strong ETag for patching. Lifecycle `ifMatch` is the -separate action-specific value in `value.data.request.actions`, never the record -ETag. The lifecycle helper refetches the contract and record, promotes actions -through the native client, and requires exactly one matching operation/stage and -the caller's held `ifMatch`. A changed proposal fails before any action POST. -Supported operation names are `submit_request`, `revise_request`, -`cancel_request`, and `apply_request`, subject to the current metadata, actor -and record state. Casework owns review decisions in Registry Stack 0.38; -approval and rejection are not BREG lifecycle actions. - -Create a governed correction with `createChangeRequest`, read the created request -with `getRecord` to obtain its current actions, then explicitly call -`executeLifecycleAction` with `operation: 'submit_request'`. Give each mutation -its own durable idempotency key derived from the source event and operation. -Creating a draft does not approve or submit it. Casework review and BREG -application remain separate actor-authorized actions. See the [create and submit job](./jobs/create-and-submit.js) -and [read and patch job](./jobs/read-and-patch.js) for synthetic templates. +serialized binding, or arbitrary action URL as authority. Action and +Proposal metadata remains descriptive; this adaptor does not execute it. + +Use the direct read's exact strong ETag for patching. This adaptor has no +change-request lifecycle operations: the 0.45 client no longer +has the lifecycle methods. The 0.45 client replaces them with metadata-declared Actions and +Proposals (`selectAction`, `invokeAction`, `startProposal`), which this adaptor +does not wrap. See the [read and patch job](./jobs/read-and-patch.js) for a +synthetic template. ## Failures and recovery Operations return `branch` values `invalid_request`, `auth_failed`, `denied`, `not_found`, `conflict`, `retryable_infrastructure`, `protocol_failed`, or `failed`. Failures include a safe `problem` with `code`, `retryable`, and optional HTTP -`status`. Typed native `code`, `planRefusal`, `traceId`, `transportKind`, and +`status`. Typed native `code`, `refusalCode`, `reasonCode`, `traceId`, `transportKind`, and `tokenKind` may be present at the result level. Exception messages, response problem prose, credentials and record values are excluded from failure results. Check the branch before consuming a value or starting a dependent mutation. @@ -114,11 +100,7 @@ Check the branch before consuming a value or starting a dependent mutation. Neither the adaptor nor native SDK retries or follows redirects. Pagination is explicit and bounded by server metadata. A temporary failure can leave a mutation outcome unknown: replay the exact request with the same key and preconditions, -never invent a new key. Keep a durable source-event correlation. The lifecycle -helper refetches current state, so an already-completed action may no longer be -advertised; it then returns `denied`. Reconcile the request's current state and -source correlation before treating this as already submitted. Do not infer -success just from a missing action. Discard held metadata, ETags, continuations, +never invent a new key. Keep a durable source-event correlation. Discard held metadata, ETags, continuations, records and drafts when identity/profile changes; review drafts after registry revision changes. @@ -133,7 +115,7 @@ npm run pack:dry-run --workspace @openfn/language-registry-breg The tests use the published native package against ephemeral synthetic HTTP servers and compile/run a real OpenFn read-and-patch job. A small CommonJS bridge copies bounded, plain JSON across the OpenFn VM boundary into the SDK realm; -opaque handles stay local. Tests cover native lookup/read/write/lifecycle HTTP +opaque handles stay local. Tests cover native lookup/read/write HTTP contracts, continuation, precondition refusal, failure redaction and runtime credential removal. Private-key JWT configuration and token transport failure are exercised; a successful live OAuth exchange and real PostgreSQL governance diff --git a/packages/registry-breg/configuration-schema.json b/packages/registry-breg/configuration-schema.json index b8a9a43..85a4de2 100644 --- a/packages/registry-breg/configuration-schema.json +++ b/packages/registry-breg/configuration-schema.json @@ -14,7 +14,7 @@ { "type": "object", "additionalProperties": false, "required": ["exchange"], "properties": { "exchange": { "type": "object", "writeOnly": true, "description": "Native Registry client exchange authorization with reviewed context and firstParty or remote source." } } } ] }, - "requestTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, + "attemptTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, "connectTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, "maximumResponseBytes": { "type": "integer", "minimum": 1 }, "userAgent": { "type": "string" }, @@ -34,7 +34,7 @@ "scopes": { "type": "array", "items": { "type": "string" } }, "assertionLifetimeSeconds": { "type": "integer", "minimum": 1 }, "refreshMarginSeconds": { "type": "integer", "minimum": 0 }, - "requestTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, + "attemptTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, "connectTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, "userAgent": { "type": "string" }, "trustedRootCertificates": { "type": "string", "description": "PEM trust roots for the token endpoint." } diff --git a/packages/registry-breg/jobs/create-and-submit.js b/packages/registry-breg/jobs/create-and-submit.js deleted file mode 100644 index b6ac3bd..0000000 --- a/packages/registry-breg/jobs/create-and-submit.js +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Supply the configured request entity's operation ID, route and profile in data. -// Credentials belong only in configuration.breg. -import { execute } from "@openfn/language-common"; -import { createChangeRequest, getRecord, executeLifecycleAction } from "../src/index.js"; - -execute( - createChangeRequest(state => ({ - operationIdentifier: state.data.createOperationIdentifier, - accessProfile: state.data.accessProfile, - data: state.data.requestFields, - idempotencyKey: state.data.createIdempotencyKey, - as: "draft", - })), - state => { - if (state.data.draft.branch !== "succeeded") return state; - return getRecord({ route: state.data.requestRoute, - recordIdentifier: state.data.draft.value.data.recordIdentifier, - accessProfile: state.data.accessProfile, as: "currentRequest" })(state); - }, - state => { - if (state.data.currentRequest?.branch !== "succeeded") return state; - const record = state.data.currentRequest.value; - const action = record.data.request?.actions?.find(item => item.operation === "submit_request"); - if (!action) return { ...state, data: { ...state.data, submission: { - branch: "denied", problem: { code: "action.unavailable", retryable: false }, - } } }; - return executeLifecycleAction({ - entityIdentifier: record.meta.entityTypeIdentifier, - route: state.data.requestRoute, - recordIdentifier: record.data.recordIdentifier, - accessProfile: state.data.accessProfile, - operation: "submit_request", - ifMatch: action.ifMatch, - idempotencyKey: state.data.submitIdempotencyKey, - as: "submission", - })(state); - }, -); diff --git a/packages/registry-breg/package.json b/packages/registry-breg/package.json index e569c3f..8395868 100644 --- a/packages/registry-breg/package.json +++ b/packages/registry-breg/package.json @@ -16,7 +16,7 @@ ], "scripts": { "test": "node --experimental-vm-modules --no-warnings --test test/*.test.js", - "check": "node --check src/index.js && node --check src/native.cjs && node --check jobs/read-and-patch.js && node --check jobs/create-and-submit.js && npm test", + "check": "node --check src/index.js && node --check src/native.cjs && node --check jobs/read-and-patch.js && npm test", "pack:dry-run": "npm pack --dry-run" }, "engines": { @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-breg/src/index.js b/packages/registry-breg/src/index.js index ddfe98c..0658317 100644 --- a/packages/registry-breg/src/index.js +++ b/packages/registry-breg/src/index.js @@ -20,9 +20,9 @@ export function discoverRegistry(options = {}) { )); } -export function getEntitySchema(options = {}) { +export function getRecordSchema(options = {}) { return operation(options, async (client, input) => document( - await client.entitySchema(required(input.entityIdentifier), input.accessProfile), + await client.recordSchema(required(input.recordTypeIdentifier), input.accessProfile), )); } @@ -59,11 +59,6 @@ export function createRecord(options = {}) { }); } -/** Create a draft in the configured change-request entity. Submission is a separate lifecycle action. */ -export function createChangeRequest(options = {}) { - return createRecord(options); -} - export function patchRecord(options = {}) { return operation(options, async (client, input) => { const key = required(input.idempotencyKey); @@ -77,33 +72,6 @@ export function patchRecord(options = {}) { }); } -/** Execute exactly one currently advertised action using a caller's held lifecycle precondition. */ -export function executeLifecycleAction(options = {}) { - return operation(options, async (client, input) => { - const key = required(input.idempotencyKey); - const ifMatch = required(input.ifMatch); - const profile = required(input.accessProfile); - const entity = required(input.entityIdentifier); - const route = required(input.route); - const recordIdentifier = required(input.recordIdentifier); - const requestedOperation = required(input.operation); - const metadata = await client.registryContract(profile); - const authority = metadata.selectLifecycle(entity, profile); - const record = await client.getRecord(route, recordIdentifier, { accessProfile: profile, format: input.format ?? "json" }); - const actions = client.lifecycleActions(authority, record.value, input.format); - const selected = actions.filter((action) => action.operation === requestedOperation - && (action.stage ?? null) === (input.stage ?? null)); - if (selected.length !== 1) throw new OperationFailure("denied", "action.unavailable"); - const action = selected[0]; - const advertised = record.value.data.request?.actions ?? []; - if (!advertised.some((item) => item.href === action.href && item.operation === action.operation - && (item.stage ?? null) === (action.stage ?? null) && item.ifMatch === ifMatch)) { - throw new OperationFailure("conflict", "action.precondition_changed"); - } - return client.executeLifecycleAction(action, key); - }); -} - function operation(options, invoke) { return async (state) => { let input; @@ -138,20 +106,20 @@ function failure(error) { } const status = Number.isSafeInteger(error.status) ? error.status : 0; let branch = "failed"; - if (error.kind === "configuration" || error.kind === "invalid_request" + if (error.kind === "configuration" || error.kind === "invalid-request" || (error.kind === "problem" && status === 400)) branch = "invalid_request"; else if (error.kind === "transport" || (error.kind === "token" && error.tokenKind === "transport") || (error.kind === "problem" && (status === 429 || status >= 500))) branch = "retryable_infrastructure"; else if (error.kind === "token" || status === 401) branch = "auth_failed"; - else if (status === 403 || error.kind === "metadata_selection") branch = "denied"; - else if (status === 404 || error.kind === "not_found") branch = "not_found"; + else if (status === 403 || error.kind === "metadata-selection") branch = "denied"; + else if (status === 404 || error.kind === "not-found") branch = "not_found"; else if (status === 409 || status === 412) branch = "conflict"; else if (error.kind === "protocol") branch = "protocol_failed"; return { branch, problem: { code: `breg.${error.kind}`, status, retryable: branch === "retryable_infrastructure" }, // Only typed diagnostics, never the SDK error message, response body, or caller inputs. - ...pick(error, ["code", "planRefusal", "traceId", "transportKind", "tokenKind"]), + ...pick(error, ["code", "refusalCode", "reasonCode", "traceId", "transportKind", "tokenKind"]), }; } diff --git a/packages/registry-breg/src/native.cjs b/packages/registry-breg/src/native.cjs index 13fdbdb..c809fe8 100644 --- a/packages/registry-breg/src/native.cjs +++ b/packages/registry-breg/src/native.cjs @@ -4,7 +4,7 @@ const { types: { isProxy } } = require("node:util"); const { breg } = require("@registrystack/client"); const { BaseRegistryClientError } = breg; -function copy(value, kind = "invalid_request") { +function copy(value, kind = "invalid-request") { const active = new Set(); let nodes = 0; let bytes = 0; @@ -48,23 +48,12 @@ function copy(value, kind = "invalid_request") { class BaseRegistryClient extends breg.BaseRegistryClient { constructor(config) { const nativeConfig = copy(config, "configuration"); - if (Object.hasOwn(nativeConfig, "maxResponseBytes")) { - throw new BaseRegistryClientError({ - kind: "configuration", - message: "Base Registry configuration uses an unsupported field", - }); - } - if (nativeConfig.maximumResponseBytes !== undefined) { - // The pinned 0.38 client uses the shorter spelling internally. - nativeConfig.maxResponseBytes = nativeConfig.maximumResponseBytes; - delete nativeConfig.maximumResponseBytes; - } super(nativeConfig); } } for (const [method, indexes] of [ ["getRecord", [2]], ["listRecords", [1]], ["continueList", [0]], - ["lookupRecord", [2, 3]], ["createRecord", [1]], ["patchRecord", [3]], ["lifecycleActions", [1]], + ["lookupRecord", [2, 3]], ["createRecord", [1]], ["patchRecord", [3]], ]) { BaseRegistryClient.prototype[method] = function (...args) { for (const index of indexes) if (args[index] !== undefined) args[index] = copy(args[index]); diff --git a/packages/registry-breg/test/breg.test.js b/packages/registry-breg/test/breg.test.js index 1a445f0..04a34fc 100644 --- a/packages/registry-breg/test/breg.test.js +++ b/packages/registry-breg/test/breg.test.js @@ -6,8 +6,8 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import compile from "@openfn/compiler"; import run from "@openfn/runtime"; -import { execute, discoverRegistry, getEntitySchema, getRecord, lookupRecord, listRecords, continueList, createRecord, patchRecord, createChangeRequest, executeLifecycleAction } from "../src/index.js"; -import { ID, ETAG, ACTION_ETAG, TRACE, PROFILE, metadata, record, receipt } from "./fixtures.js"; +import { execute, discoverRegistry, getRecordSchema, getRecord, lookupRecord, listRecords, continueList, createRecord, patchRecord } from "../src/index.js"; +import { ID, ETAG, TRACE, PROFILE, metadata, record } from "./fixtures.js"; async function server(handler) { const requests = []; @@ -29,7 +29,6 @@ function state(baseUrl) { return { configuration: { breg: { baseUrl, authorizati function regular(req, res) { if (req.url.includes("/v1/registry")) return res.end(JSON.stringify(metadata())); if (req.url.includes("/v1/schemas/")) return res.end('{"type":"object"}'); - if (req.url.includes("/actions/")) { res.removeHeader("link"); return res.end(JSON.stringify(receipt())); } if (!req.url.includes("lookup")) res.setHeader("etag", ETAG); if (req.method === "POST" && !req.url.includes("lookup")) { res.statusCode = 201; @@ -38,13 +37,12 @@ function regular(req, res) { res.end(JSON.stringify(record())); } const write = { operationIdentifier: "records.company.create", accessProfile: PROFILE, idempotencyKey: "synthetic-create-001", data: { legalName: "Example Ltd" } }; -const lifecycle = { entityIdentifier: "company", route: "companies", recordIdentifier: ID, accessProfile: PROFILE, operation: "submit_request", ifMatch: ACTION_ETAG, idempotencyKey: "synthetic-submit-001" }; test("native discovery, exact lookup, read, and schema preserve composition configuration", async () => { const stub = await server(regular); try { const initial = state(stub.baseUrl); - const result = await execute(discoverRegistry({ accessProfile: PROFILE, as: "metadata" }), lookupRecord({ route: "companies", selector: "by-name", values: { legalName: "Example Ltd" }, accessProfile: PROFILE, as: "lookup" }), getRecord({ route: "companies", recordIdentifier: ID, as: "record" }), getEntitySchema({ entityIdentifier: "company", as: "schema" }))(initial); + const result = await execute(discoverRegistry({ accessProfile: PROFILE, as: "metadata" }), lookupRecord({ route: "companies", selector: "by-name", values: { legalName: "Example Ltd" }, accessProfile: PROFILE, as: "lookup" }), getRecord({ route: "companies", recordIdentifier: ID, as: "record" }), getRecordSchema({ recordTypeIdentifier: "company", as: "schema" }))(initial); for (const key of ["metadata", "lookup", "record", "schema"]) assert.equal(result.data[key].branch, "succeeded", JSON.stringify(result.data[key])); assert.equal(result.configuration, initial.configuration); assert.deepEqual(JSON.parse(stub.requests[1].body), { selector: "by-name", values: { legalName: "Example Ltd" } }); @@ -89,32 +87,13 @@ test("metadata-selected create and patch carry caller keys and exact record ETag test("missing keys/preconditions and arbitrary operation URLs cannot write", async () => { const stub = await server(regular); try { - for (const op of [createRecord({ ...write, idempotencyKey: undefined }), patchRecord({ ...write, etag: undefined }), executeLifecycleAction({ ...lifecycle, ifMatch: undefined })]) { + for (const op of [createRecord({ ...write, idempotencyKey: undefined }), patchRecord({ ...write, etag: undefined })]) { assert.equal((await op(state(stub.baseUrl))).data.breg.branch, "invalid_request"); } assert.equal(stub.requests.length, 0); const result = await createRecord({ ...write, operationIdentifier: "https://attacker.invalid/write" })(state(stub.baseUrl)); assert.equal(result.data.breg.branch, "denied"); assert.equal(stub.requests.length, 1); - const draft = await createChangeRequest(write)(state(stub.baseUrl)); - assert.equal(draft.data.breg.branch, "succeeded"); - } finally { await stub.close(); } -}); - -test("native lifecycle promotion executes exact action and refuses a changed precondition", async () => { - const stub = await server((req, res) => { - if (req.method === "GET" && req.url.includes(`/companies/${ID}`)) { res.setHeader("etag", ETAG); return res.end(JSON.stringify(record(true))); } - regular(req, res); - }); - try { - const result = await executeLifecycleAction(lifecycle)(state(stub.baseUrl)); - assert.equal(result.data.breg.branch, "succeeded", JSON.stringify(result.data.breg)); - assert.equal(stub.requests[2].headers["if-match"], ACTION_ETAG); - assert.equal(stub.requests[2].headers["idempotency-key"], lifecycle.idempotencyKey); - assert.deepEqual(JSON.parse(stub.requests[2].body), {}); - const stale = await executeLifecycleAction({ ...lifecycle, ifMatch: ETAG })(state(stub.baseUrl)); - assert.equal(stale.data.breg.branch, "conflict"); - assert.equal(stub.requests.filter(req => req.method === "POST").length, 1); } finally { await stub.close(); } }); @@ -149,7 +128,7 @@ test("safe stable failures distinguish not found, denied, conflict and temporary } }); -test("0.38 located request and query refusals stay invalid requests without exposing paths", async () => { +test("located request and query refusals stay invalid requests without exposing paths", async () => { for (const [code, detail, fieldPath] of [ ["request.invalid", "The request is invalid.", "/data/legalName"], ["query.invalid", "The query request is invalid.", "$select"], @@ -175,7 +154,7 @@ test("0.38 located request and query refusals stay invalid requests without expo test("privateKeyJwt configuration reaches native validation without disclosing keys", async () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); const key = { ...privateKey.export({ format: "jwk" }), alg: "ES256", kid: "synthetic-key" }; - const configured = { configuration: { baseUrl: "http://127.0.0.1:1", authorization: { privateKeyJwt: { tokenEndpoint: "https://127.0.0.1:1/token", clientId: "synthetic-client", clientKey: key, requestTimeoutMilliseconds: 100, connectTimeoutMilliseconds: 100 } } }, data: {} }; + const configured = { configuration: { baseUrl: "http://127.0.0.1:1", authorization: { privateKeyJwt: { tokenEndpoint: "https://127.0.0.1:1/token", clientId: "synthetic-client", clientKey: key, attemptTimeoutMilliseconds: 100, connectTimeoutMilliseconds: 100 } } }, data: {} }; const result = await discoverRegistry()(configured); assert.equal(result.data.breg.branch, "retryable_infrastructure", JSON.stringify(result.data.breg)); assert.ok(!JSON.stringify(result.data).includes(key.d)); @@ -226,23 +205,6 @@ test("malformed plain-data graphs fail without accessing properties or sending m } finally { await stub.close(); } }); -test("record action URLs cannot create authority outside metadata", async () => { - const stub = await server((req, res) => { - if (req.url.includes(`/companies/${ID}`)) { - const body = record(true); - body.data.request.actions[0].href = "https://attacker.invalid/mutate"; - return res.end(JSON.stringify(body)); - } - regular(req, res); - }); - try { - const result = await executeLifecycleAction(lifecycle)(state(stub.baseUrl)); - assert.notEqual(result.data.breg.branch, "succeeded"); - assert.equal(stub.requests.length, 2); - assert.equal(stub.requests.some(req => req.method !== "GET"), false); - } finally { await stub.close(); } -}); - test("redirects and malformed success responses fail closed without automatic follow-up", async () => { for (const redirect of [true, false]) { const stub = await server((_req, res) => { @@ -258,26 +220,6 @@ test("redirects and malformed success responses fail closed without automatic fo } }); -test("compiled governed draft creation and explicit submission keep separate keys", async () => { - const stub = await server((req, res) => { - if (req.url.includes("/v1/registry") || req.url.includes("/actions/")) return regular(req, res); - res.setHeader("etag", ETAG); - if (req.method === "POST") { res.statusCode = 201; res.setHeader("location", `/tenant/v1/records/companies/${ID}`); } - const value = record(req.method !== "POST"); - res.end(JSON.stringify(value)); - }); - try { - const { code } = compile(readFileSync(new URL("../jobs/create-and-submit.js", import.meta.url), "utf8")); - const result = await run({ workflow: { steps: [{ id: "breg", expression: code }], start: "breg" }, options: { start: "breg" } }, - { ...state(stub.baseUrl), data: { createOperationIdentifier: "records.company.create", accessProfile: PROFILE, requestFields: { legalName: "Example Ltd" }, createIdempotencyKey: "synthetic-draft-001", submitIdempotencyKey: "synthetic-draft-submit-001", requestRoute: "companies" } }, - { linker: { modules: { "@openfn/language-common": { path: fileURLToPath(new URL("../../../node_modules/@openfn/language-common", import.meta.url)) }, "../src/index.js": { path: fileURLToPath(new URL("..", import.meta.url)) } }, cacheKey: `breg-submit-${process.pid}` } }); - assert.equal(result.errors, undefined, JSON.stringify(result.errors)); - assert.equal(result.data.submission?.branch, "succeeded", JSON.stringify(result.data)); - assert.equal("configuration" in result, false); - assert.deepEqual(stub.requests.filter(req => req.method === "POST").map(req => req.headers["idempotency-key"]), ["synthetic-draft-001", "synthetic-draft-submit-001"]); - } finally { await stub.close(); } -}); - test("stale patch ETag conflicts without overwriting or acquiring a replacement precondition", async () => { let current = record(); let currentEtag = ETAG; diff --git a/packages/registry-breg/test/fixtures.js b/packages/registry-breg/test/fixtures.js index 35933dd..d78287e 100644 --- a/packages/registry-breg/test/fixtures.js +++ b/packages/registry-breg/test/fixtures.js @@ -1,21 +1,19 @@ // Synthetic HTTP fixtures follow registry-breg-client/tests/write_http_boundary.rs. export const ID = "00000000-0000-4000-8000-000000000001"; export const ETAG = '"breg-record-000000000001"'; -export const ACTION_ETAG = '"breg-action-hmac-sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"'; -export const DIGEST = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; export const TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"; export const PROFILE = "company-writer"; const field = { id: "legal-name", apiName: "legalName", label: "Legal name", schema: { type: "string" }, required: true, nullable: true, readOnly: false, removable: true }; function operation(id, method, path, kind, request, create = [], patch = []) { - return { id, method, path, operation: kind, sourceEntity: "company", responseEntity: "company", accessProfile: PROFILE, - requiredCapabilities: kind === "submit_request" ? ["change_request_lifecycle"] : [], entityLabel: "Companies", - identifier: { apiName: "id", location: "envelope" }, titleFields: ["legal-name"], fields: [field], readableFields: ["legal-name"], + return { id, method, path, operation: kind, sourceRecordType: "company", responseRecordType: "company", accessProfile: PROFILE, + requiredCapabilities: [], recordTypeLabel: "Companies", + identifier: { apiName: "id", location: "envelope" }, titleFields: ["legal-name"], fields: [field], readableFields: ["legal-name"], readableRequestFields: [], createWritableFields: create, patchWritableFields: patch, selectors: [], query: null, request: { fieldNames: "api", queryParameters: [], ...request } }; } export function metadata() { - return { id: "business-registry", version: "1.2.3", revision: `sha256:${"a".repeat(64)}`, metadataVersion: "1", - entities: [{ id: "company", datasetIdentifier: "legal-entities", route: "companies", operations: [ - { operation: "create", accessProfile: PROFILE }, { operation: "patch", accessProfile: PROFILE }, { operation: "submit_request", accessProfile: PROFILE }, + return { id: "business-registry", version: "1.2.3", revision: `sha256:${"a".repeat(64)}`, metadataVersion: "2", + recordTypes: [{ id: "company", datasetIdentifier: "legal-entities", route: "companies", operations: [ + { operation: "create", accessProfile: PROFILE }, { operation: "patch", accessProfile: PROFILE } ], readableFields: ["legal-name"], schema: "/v1/schemas/company" }], operations: [ operation("records.company.create", "POST", "/v1/records/companies", "create", { @@ -27,17 +25,10 @@ export function metadata() { removeSemantics: "set_null", ifMatchRequired: true, idempotencyKeyRequired: true, mutationSemantics: "direct", schema: { type: "array", items: { oneOf: [{ type: "object" }] } }, }, [], ["legal-name"]), - operation("records.company.request.submit", "POST", "/v1/records/companies/{record_id}/actions/submit", "submit_request", { - body: "change_request_action", contentType: "application/json", ifMatchRequired: true, idempotencyKeyRequired: true, mutationSemantics: "change_request_lifecycle", - schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", additionalProperties: false, properties: {} }, - }), ], }; } -export function record(lifecycle = false) { - return { data: { recordIdentifier: ID, revisionIdentifier: lifecycle ? "7" : "1", domainData: { legalName: "Example Ltd" }, snapshot: `breg1_${ID}`, - ...(lifecycle ? { request: { bregState: "draft", proposalVersion: 7, effectDigest: DIGEST, editable: true, - actions: [{ operation: "submit_request", method: "POST", href: `/v1/records/companies/${ID}/actions/submit?accessProfile=${PROFILE}`, ifMatch: ACTION_ETAG }] } } : {}), - }, meta: { registryIdentifier: "business-registry", datasetIdentifier: "legal-entities", entityTypeIdentifier: "company" } }; +export function record() { + return { data: { recordIdentifier: ID, revisionIdentifier: "1", domainData: { legalName: "Example Ltd" } }, + meta: { registryIdentifier: "business-registry", datasetIdentifier: "legal-entities", recordTypeIdentifier: "company" } }; } -export function receipt() { return { id: ID, revision: 8, snapshot: `breg1_${ID}`, request: { bregState: "submitted", proposalVersion: 7, effectDigest: DIGEST, application: null } }; } diff --git a/packages/registry-casework/README.md b/packages/registry-casework/README.md index 063c078..dd46d54 100644 --- a/packages/registry-casework/README.md +++ b/packages/registry-casework/README.md @@ -1,7 +1,7 @@ # Registry Casework Requester adaptor This OpenFn adaptor wraps the Requester review API in the published -Registry Stack Node client 0.38.0. It creates or recovers review requests, +Registry Stack Node client 0.45.0. It creates or recovers review requests, reads request state and correlated results, adds requester-visible notes, pages requester-visible history and result feeds, and cancels an accepted request. It also preserves the maintained source-inbox and task-grant inspection surface @@ -31,12 +31,11 @@ For a refreshing service credential, replace `token` with scopes } }`. Configure exactly one credential source. The adaptor validates the operation before asking the native provider for a token. -`requestTimeoutMilliseconds`, `connectTimeoutMilliseconds`, +`attemptTimeoutMilliseconds`, `connectTimeoutMilliseconds`, `maximumResponseBytes`, `userAgent`, and `trustedRootCertificates` pass to the maintained client. Put Casework settings under `configuration.casework`; unknown fields in that namespace fail before client construction. Other configuration -namespaces remain available to other adaptors. The 0.38 client uses the shorter -`maxResponseBytes` spelling internally, which this adaptor maps at its boundary. +namespaces remain available to other adaptors. The token and profile never enter operation output. OpenFn keeps configuration available during a composed job and removes it from final job output. @@ -62,14 +61,17 @@ Create and cancel each make one maintained-client call. The adaptor never generates an idempotency key, retries a mutation, replaces a digest, or rebuilds an accepted binding. -## Source inbox and task-grant operations +## Review task and task-grant operations -- `listCaseworkWorkItems` requires `sourceProfile` and an exact native `query`. -- `getCaseworkWorkItem`, `previewCaseworkTaskTemplates`, and - `listCaseworkTaskGrants` require `sourceProfile` and `itemId`. +- `listCaseworkReviewTasks` takes an optional exact native staffing `query` and + an optional `sourceProfile`. +- `getCaseworkReviewTask`, `previewCaseworkTaskTemplates`, and + `listCaseworkTaskGrants` require `taskId` and accept an optional + `sourceProfile`. - `approveCaseworkTaskGrant` also requires the held `expectedRevision`, a caller-owned `idempotencyKey`, `templateId`, and `templateVersion`. -- `revokeCaseworkTaskGrant` requires `sourceProfile`, `itemId`, and `grantId`. +- `revokeCaseworkTaskGrant` requires `taskId` and `grantId`; the native client + rejects a source profile on revoke. - `caseworkTaskGrantStatus` requires `grantId`. The adaptor passes source authority, revisions, and keys to the native client @@ -83,11 +85,11 @@ credentials, configuration, and caller inputs are not copied into the result. ## Request body -The create request follows the native 0.38.0 contract. For submitted context: +The create request follows the native 0.45.0 contract. For submitted context: ```json { - "kind": "decision", + "policy": { "id": "batch-validation" }, "subject": { "source": "payments", "type": "batch", @@ -97,11 +99,12 @@ The create request follows the native 0.38.0 contract. For submitted context: }, "requesterReference": "batch-0042", "context": { - "strategy": "submitted", + "mode": "submitted", "snapshot": { "summary": "Review batch 42" } - } + }, + "resultConstraints": { "acceptedCount": { "minimum": 0 } } } ``` @@ -137,7 +140,7 @@ execute( ## Verification -The package check exercises fake bindings and the installed 0.38.0 native +The package check exercises fake bindings and the installed 0.45.0 native client against a loopback HTTP service: ```sh diff --git a/packages/registry-casework/configuration-schema.json b/packages/registry-casework/configuration-schema.json index 1e1c36f..fe84d34 100644 --- a/packages/registry-casework/configuration-schema.json +++ b/packages/registry-casework/configuration-schema.json @@ -54,7 +54,7 @@ "maxLength": 128, "description": "Configured Casework Requester profile bound to this credential." }, - "requestTimeoutMilliseconds": { + "attemptTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, diff --git a/packages/registry-casework/package.json b/packages/registry-casework/package.json index a1c8da8..313c9ce 100644 --- a/packages/registry-casework/package.json +++ b/packages/registry-casework/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-casework/src/index.js b/packages/registry-casework/src/index.js index 65ae0d2..ac18bfe 100644 --- a/packages/registry-casework/src/index.js +++ b/packages/registry-casework/src/index.js @@ -16,8 +16,8 @@ export const { addReviewNote, listReviewHistory, cancelReviewRequest, - listCaseworkWorkItems, - getCaseworkWorkItem, + listCaseworkReviewTasks, + getCaseworkReviewTask, previewCaseworkTaskTemplates, listCaseworkTaskGrants, approveCaseworkTaskGrant, diff --git a/packages/registry-casework/src/native.cjs b/packages/registry-casework/src/native.cjs index a3f7ead..4c7fddf 100644 --- a/packages/registry-casework/src/native.cjs +++ b/packages/registry-casework/src/native.cjs @@ -5,7 +5,7 @@ const { types: { isProxy } } = require("node:util"); const { breg, casework } = require("@registrystack/client"); const { CaseworkClientError } = casework; -function copy(value, kind = "invalid_request") { +function copy(value, kind = "invalid-request") { const active = new Set(); let nodes = 0; let bytes = 0; @@ -85,8 +85,10 @@ for (const [method, indexes] of [ ["cancelReviewRequest", [2, 4]], ["reviewHistory", [3]], ["addReviewNote", [4]], - ["listWorkItems", [3]], - ["approveTaskGrant", [6]], + ["reviewTasks", [2]], + ["approveReviewTaskGrant", [5]], + ["previewReviewTaskTemplates", [2]], + ["listReviewTaskGrants", [2]], ]) { CaseworkClient.prototype[method] = function (...args) { for (const index of indexes) { diff --git a/packages/registry-casework/src/operations.js b/packages/registry-casework/src/operations.js index efea1f0..a7bffb1 100644 --- a/packages/registry-casework/src/operations.js +++ b/packages/registry-casework/src/operations.js @@ -2,7 +2,7 @@ const CLIENT_CONFIG_FIELDS = [ "baseUrl", - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", "maximumResponseBytes", "userAgent", @@ -15,20 +15,25 @@ const CASEWORK_CONFIG_FIELDS = new Set([ "profile", ]); const SAFE_VALIDATION_REASONS = new Set([ - "kind_not_allowed", - "reference_invalid", - "object_required", - "maximum_bytes_exceeded", - "maximum_depth_exceeded", - "schema_mismatch", - "outcome_not_declared", - "reason_required", - "text_invalid", - "result_not_declared", - "result_required", - "field_not_declared", - "constraint_invalid", - "constraint_violated", + "binding-invalid", + "field-required", + "policy-not-allowed", + "reference-invalid", + "object-required", + "maximum-bytes-exceeded", + "maximum-depth-exceeded", + "schema-mismatch", + "outcome-not-declared", + "reason-required", + "text-invalid", + "result-not-declared", + "result-required", + "field-not-declared", + "constraint-invalid", + "constraint-violated", + "public-reason-required", + "public-reason-not-declared", + "approval-outcome-differs", ]); const RESULT_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const SAFE_CODE = /^[a-z0-9][a-z0-9._-]{0,127}$/; @@ -174,50 +179,48 @@ export function createCaseworkOperations(loadBindings) { ), ), - listCaseworkWorkItems: (options = {}) => + listCaseworkReviewTasks: (options = {}) => operation( loadBindings, options, - "caseworkWorkItems", + "caseworkReviewTasks", null, (input) => { - requiredString(input.sourceProfile, "sourceProfile"); - requiredObject(input.query, "query"); + optionalString(input.sourceProfile, "sourceProfile"); + if (input.query !== undefined) requiredObject(input.query, "query"); }, (client, auth, input) => - client.listWorkItems( + client.reviewTasks( auth.token, auth.profile, + input.query ?? {}, input.sourceProfile, - input.query, ), ), - getCaseworkWorkItem: (options = {}) => - sourceItemOperation(loadBindings, options, "caseworkWorkItem", (client, auth, input) => - client.getWorkItem( + getCaseworkReviewTask: (options = {}) => + taskOperation(loadBindings, options, "caseworkReviewTask", (client, auth, input) => + client.reviewTask( auth.token, auth.profile, + input.taskId, input.sourceProfile, - input.itemId, )), previewCaseworkTaskTemplates: (options = {}) => - sourceItemOperation(loadBindings, options, "caseworkTaskTemplates", (client, auth, input) => - client.previewTaskTemplates( + taskOperation(loadBindings, options, "caseworkTaskTemplates", (client, auth, input) => + client.previewReviewTaskTemplates( auth.token, auth.profile, - input.sourceProfile, - input.itemId, + taskGrantReadOptions(input), )), listCaseworkTaskGrants: (options = {}) => - sourceItemOperation(loadBindings, options, "caseworkTaskGrants", (client, auth, input) => - client.listTaskGrants( + taskOperation(loadBindings, options, "caseworkTaskGrants", (client, auth, input) => + client.listReviewTaskGrants( auth.token, auth.profile, - input.sourceProfile, - input.itemId, + taskGrantReadOptions(input), )), approveCaseworkTaskGrant: (options = {}) => @@ -227,24 +230,24 @@ export function createCaseworkOperations(loadBindings) { "caseworkTaskGrant", null, (input) => { - sourceItem(input); + reviewTask(input); requiredRevision(input.expectedRevision); requiredString(input.idempotencyKey, "idempotencyKey"); requiredString(input.templateId, "templateId"); requiredString(input.templateVersion, "templateVersion"); }, (client, auth, input) => - client.approveTaskGrant( + client.approveReviewTaskGrant( auth.token, auth.profile, - input.sourceProfile, - input.itemId, + input.taskId, input.expectedRevision, input.idempotencyKey, { templateId: input.templateId, templateVersion: input.templateVersion, }, + input.sourceProfile, ), ), @@ -255,15 +258,14 @@ export function createCaseworkOperations(loadBindings) { "caseworkTaskRevocation", null, (input) => { - sourceItem(input); + requiredString(input.taskId, "taskId"); requiredString(input.grantId, "grantId"); }, (client, auth, input) => - client.revokeTaskGrant( + client.revokeReviewTaskGrant( auth.token, auth.profile, - input.sourceProfile, - input.itemId, + input.taskId, input.grantId, ), ), @@ -280,13 +282,20 @@ export function createCaseworkOperations(loadBindings) { }; } -function sourceItemOperation(loadBindings, options, defaultName, invoke) { - return operation(loadBindings, options, defaultName, null, sourceItem, invoke); +function taskOperation(loadBindings, options, defaultName, invoke) { + return operation(loadBindings, options, defaultName, null, reviewTask, invoke); +} + +function reviewTask(input) { + requiredString(input.taskId, "taskId"); + optionalString(input.sourceProfile, "sourceProfile"); } -function sourceItem(input) { - requiredString(input.sourceProfile, "sourceProfile"); - requiredString(input.itemId, "itemId"); +function taskGrantReadOptions(input) { + return { + taskId: input.taskId, + ...(input.sourceProfile === undefined ? {} : { sourceProfile: input.sourceProfile }), + }; } function operation( @@ -327,11 +336,6 @@ function operation( const configuration = caseworkConfiguration(state); const auth = await requesterAuthority(configuration, PrivateKeyJwt); const clientConfiguration = pick(configuration, CLIENT_CONFIG_FIELDS); - if (clientConfiguration.maximumResponseBytes !== undefined) { - // The pinned 0.38 client uses the shorter spelling internally. - clientConfiguration.maxResponseBytes = clientConfiguration.maximumResponseBytes; - delete clientConfiguration.maximumResponseBytes; - } const client = new CaseworkClient(clientConfiguration); result = normalize(await invoke(client, auth, input)); } catch (error) { @@ -478,7 +482,7 @@ function caseworkConfiguration(state) { } boundedString(configuration.profile, "configuration.casework.profile", 128); for (const field of [ - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", "maximumResponseBytes", ]) { @@ -532,6 +536,10 @@ function requiredString(value, label) { return value; } +function optionalString(value, label) { + if (value !== undefined) requiredString(value, label); +} + function boundedString(value, label, maximumLength) { requiredString(value, label); if (value.length > maximumLength) { diff --git a/packages/registry-casework/test/casework.test.js b/packages/registry-casework/test/casework.test.js index 73ce079..68fc5cc 100644 --- a/packages/registry-casework/test/casework.test.js +++ b/packages/registry-casework/test/casework.test.js @@ -30,11 +30,11 @@ const subject = { digest: `sha256:${"a".repeat(64)}`, }; const request = { - kind: "batch-validation", + policy: { id: "batch-validation" }, subject, requesterReference: "batch-0042", context: { - strategy: "submitted", + mode: "submitted", snapshot: { summary: "Review batch 42" }, }, resultConstraints: { acceptedCount: { minimum: 0 } }, @@ -73,12 +73,12 @@ function fakeOperations(handlers = {}) { "addReviewNote", "reviewHistory", "cancelReviewRequest", - "listWorkItems", - "getWorkItem", - "previewTaskTemplates", - "listTaskGrants", - "approveTaskGrant", - "revokeTaskGrant", + "reviewTasks", + "reviewTask", + "previewReviewTaskTemplates", + "listReviewTaskGrants", + "approveReviewTaskGrant", + "revokeReviewTaskGrant", "taskGrantStatus", ]) { FakeCaseworkClient.prototype[method] = async function (...args) { @@ -125,8 +125,8 @@ test("adaptor exports current Requester review and bounded task operations", () "addReviewNote", "listReviewHistory", "cancelReviewRequest", - "listCaseworkWorkItems", - "getCaseworkWorkItem", + "listCaseworkReviewTasks", + "getCaseworkReviewTask", "previewCaseworkTaskTemplates", "listCaseworkTaskGrants", "approveCaseworkTaskGrant", @@ -143,6 +143,8 @@ test("adaptor exports current Requester review and bounded task operations", () "claimReviewTask", "decideReviewTask", "listWorkItems", + "listCaseworkWorkItems", + "getCaseworkWorkItem", "taskAssertion", ]) { assert.equal(removedOrStaff in adaptor, false); @@ -320,7 +322,7 @@ test("typed conflicts expose only bounded diagnostics", async () => { status: 409, detail: "secret-cancellation-canary", message: "secret-message-canary", - validation: { path: "/reason", reason: "text_invalid" }, + validation: { path: "/reason", reason: "text-invalid" }, }); }, }); @@ -336,7 +338,7 @@ test("typed conflicts expose only bounded diagnostics", async () => { status: 409, retryable: false, }, - validation: { path: "/reason", reason: "text_invalid" }, + validation: { path: "/reason", reason: "text-invalid" }, }); assert.equal(JSON.stringify(result).includes("secret-cancellation-canary"), false); assert.equal(JSON.stringify(result).includes("secret-message-canary"), false); @@ -521,7 +523,10 @@ test("installed native client completes the Requester review loop over HTTP", as assert.equal(stub.requests.length, 9); for (const observed of stub.requests) { assert.equal(observed.headers.authorization, "Bearer synthetic-requester-secret"); - assert.equal(observed.headers["registry-casework-profile"], "requester"); + assert.equal( + observed.headers["registry-review-profile"] ?? observed.headers["registry-casework-profile"], + "requester", + ); assert.equal(observed.headers["registry-source-profile"], undefined); } assert.deepEqual(JSON.parse(stub.requests[0].body), request); @@ -564,7 +569,7 @@ test("VM normalization refuses unsafe graphs before the native client sends", () invalidRequest, accepted.submissionDigest, ), - (error) => error instanceof CaseworkClientError && error.kind === "invalid_request", + (error) => error instanceof CaseworkClientError && error.kind === "invalid-request", ); } assert.equal(accessorRead, false); @@ -577,7 +582,7 @@ test("package pins the published native client and authentication schema", () => const schema = JSON.parse( readFileSync(new URL("../configuration-schema.json", import.meta.url), "utf8"), ); - assert.equal(manifest.dependencies["@registrystack/client"], "0.38.0"); + assert.equal(manifest.dependencies["@registrystack/client"], "0.45.0"); assert.deepEqual(schema.required, ["baseUrl", "profile"]); assert.deepEqual(schema.oneOf, [ { required: ["token"] }, @@ -586,55 +591,72 @@ test("package pins the published native client and authentication schema", () => assert.equal(schema.properties.token.writeOnly, true); assert.equal(schema.properties.maximumResponseBytes.type, "integer"); assert.equal(schema.properties.maxResponseBytes, undefined); + assert.equal(schema.properties.attemptTimeoutMilliseconds.type, "integer"); + assert.equal(schema.properties.requestTimeoutMilliseconds, undefined); }); -test("source task inspection and grants preserve held authority", async () => { +test("review task inspection and grants preserve held authority", async () => { const { calls, operations } = fakeOperations(); let current = state(); - current = await operations.listCaseworkWorkItems({ + const query = { ownership: "unclaimed", limit: 20 }; + current = await operations.listCaseworkReviewTasks({ sourceProfile: "reviewer", - query: { view: "my_teams", limit: 20 }, + query, })(current); - current = await operations.getCaseworkWorkItem({ + current = await operations.listCaseworkReviewTasks({})(current); + current = await operations.getCaseworkReviewTask({ sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, })(current); current = await operations.previewCaseworkTaskTemplates({ sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, })(current); current = await operations.listCaseworkTaskGrants({ - sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, })(current); current = await operations.approveCaseworkTaskGrant({ sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, expectedRevision: 7, idempotencyKey: "approval-key-004", templateId: "verify", templateVersion: "1", })(current); current = await operations.revokeCaseworkTaskGrant({ - sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, grantId: "grant-1", })(current); current = await operations.caseworkTaskGrantStatus({ grantId: "grant-1" })(current); assert.deepEqual(calls.filter(([method]) => method !== "constructor"), [ - ["listWorkItems", "synthetic-requester-secret", "requester", "reviewer", { view: "my_teams", limit: 20 }], - ["getWorkItem", "synthetic-requester-secret", "requester", "reviewer", REQUEST_ID], - ["previewTaskTemplates", "synthetic-requester-secret", "requester", "reviewer", REQUEST_ID], - ["listTaskGrants", "synthetic-requester-secret", "requester", "reviewer", REQUEST_ID], - ["approveTaskGrant", "synthetic-requester-secret", "requester", "reviewer", REQUEST_ID, 7, "approval-key-004", { templateId: "verify", templateVersion: "1" }], - ["revokeTaskGrant", "synthetic-requester-secret", "requester", "reviewer", REQUEST_ID, "grant-1"], + ["reviewTasks", "synthetic-requester-secret", "requester", query, "reviewer"], + ["reviewTasks", "synthetic-requester-secret", "requester", {}, undefined], + ["reviewTask", "synthetic-requester-secret", "requester", REQUEST_ID, "reviewer"], + ["previewReviewTaskTemplates", "synthetic-requester-secret", "requester", { taskId: REQUEST_ID, sourceProfile: "reviewer" }], + ["listReviewTaskGrants", "synthetic-requester-secret", "requester", { taskId: REQUEST_ID }], + ["approveReviewTaskGrant", "synthetic-requester-secret", "requester", REQUEST_ID, 7, "approval-key-004", { templateId: "verify", templateVersion: "1" }, "reviewer"], + ["revokeReviewTaskGrant", "synthetic-requester-secret", "requester", REQUEST_ID, "grant-1"], ["taskGrantStatus", "synthetic-requester-secret", "grant-1"], ]); assert.equal(current.data.caseworkTaskStatus.branch, "succeeded"); assert.equal("taskAssertion" in operations, false); }); +test("review task operations require a task id before client construction", async () => { + const { calls, operations } = fakeOperations(); + for (const execute of [ + operations.getCaseworkReviewTask({ sourceProfile: "reviewer" }), + operations.previewCaseworkTaskTemplates({}), + operations.listCaseworkTaskGrants({ itemId: REQUEST_ID }), + operations.revokeCaseworkTaskGrant({ grantId: "grant-1" }), + ]) { + const result = await execute(state()); + assert.equal(Object.values(result.data).at(-1).branch, "invalid_request"); + } + assert.equal(calls.length, 0); +}); + test("private-key JWT authentication validates the operation before minting", async () => { const calls = []; class PrivateKeyJwt { @@ -745,7 +767,7 @@ test("Casework uses only its closed namespace and forwards the canonical respons assert.equal(result.data.reviewRequest.branch, "succeeded"); assert.deepEqual(calls.find(([method]) => method === "constructor")?.[1], { baseUrl: configured.configuration.casework.baseUrl, - maxResponseBytes: 1_000_000, + maximumResponseBytes: 1_000_000, }); configured.configuration.casework.maxResponseByes = 1234; diff --git a/packages/registry-casework/test/native.test.js b/packages/registry-casework/test/native.test.js index 3f8d7f3..cee2ad4 100644 --- a/packages/registry-casework/test/native.test.js +++ b/packages/registry-casework/test/native.test.js @@ -11,7 +11,7 @@ import { approveCaseworkTaskGrant, createOrRecoverReviewRequest, getReviewRequest, - listCaseworkWorkItems, + listCaseworkReviewTasks, } from "../src/index.js"; const REQUEST_ID = "00000000-0000-4000-8000-000000000001"; @@ -23,13 +23,14 @@ const subject = { digest: `sha256:${"a".repeat(64)}`, }; const request = { - kind: "batch-validation", + policy: { id: "batch-validation" }, subject, requesterReference: "batch-0042", context: { - strategy: "submitted", + mode: "submitted", snapshot: { summary: "Review batch 42" }, }, + resultConstraints: { acceptedCount: { minimum: 0 } }, }; const accepted = { requestId: REQUEST_ID, @@ -57,14 +58,12 @@ test("installed client preserves requester and source-task authority", async (co res.statusCode = 201; return res.end(JSON.stringify(accepted)); } - if (req.method === "GET" && req.url?.startsWith("/v1/work-items?")) { - return res.end( - JSON.stringify({ items: [], servedQueues: ["review"], status: "complete" }), - ); + if (req.method === "GET" && req.url?.startsWith("/v1/review-tasks?")) { + return res.end(JSON.stringify({ items: [], status: "complete" })); } if ( req.method === "POST" && - req.url === `/v1/work-items/${REQUEST_ID}/task-grants` + req.url === `/v1/review-tasks/${REQUEST_ID}/task-grants` ) { return res.end( JSON.stringify({ @@ -115,20 +114,20 @@ test("installed client preserves requester and source-task authority", async (co assert.equal(requests[0].headers["idempotency-key"], "batch-0042:create"); assert.deepEqual(JSON.parse(requests[0].body), request); - const listed = await listCaseworkWorkItems({ + const listed = await listCaseworkReviewTasks({ sourceProfile: "reviewer", - query: { view: "my_teams", limit: 20 }, + query: { ownership: "unclaimed", limit: 20 }, })(created); assert.equal( - listed.data.caseworkWorkItems.branch, + listed.data.caseworkReviewTasks.branch, "succeeded", - JSON.stringify(listed.data.caseworkWorkItems), + JSON.stringify(listed.data.caseworkReviewTasks), ); assert.equal(requests[1].headers["registry-source-profile"], "reviewer"); const approved = await approveCaseworkTaskGrant({ sourceProfile: "reviewer", - itemId: REQUEST_ID, + taskId: REQUEST_ID, expectedRevision: 7, idempotencyKey: "grant-42", templateId: "verify", diff --git a/packages/registry-evidence/README.md b/packages/registry-evidence/README.md index a514401..9d0e9be 100644 --- a/packages/registry-evidence/README.md +++ b/packages/registry-evidence/README.md @@ -12,7 +12,7 @@ as: ## Client packaging -This package pins `@registrystack/client` to **0.38.0** and uses its `evidence` +This package pins `@registrystack/client` to **0.45.0** and uses its `evidence` namespace. Supported native targets are macOS arm64, Linux amd64 glibc, and Linux arm64 glibc. Alpine/musl is unsupported. Use a glibc OpenFn worker image. @@ -55,8 +55,8 @@ private-key-JWT client-credentials exchange, replace `token` with: Store private material in the OpenFn credential, never job source or state.data. The native SDK validates the key, performs the exchange, and owns token caching. Optional SDK timeout and private-CA fields pass through intact. The authored -response bound is `maximumResponseBytes`; the adaptor maps it to the pinned 0.38 -client's internal `maxResponseBytes` field. The old camelCase spelling is +response bound is `maximumResponseBytes`, which the native client takes as is. The old +`maxResponseBytes` spelling is refused under `configuration.evidence` rather than silently ignored or treated as SDK-specific configuration. Exactly one token source is allowed. Existing flat credentials @@ -90,7 +90,7 @@ execute( }, ], expectedOutputs: [ - { concept: "urn:example:concept:adult-status", form: "boolean" }, + { concept: "urn:example:concept:adult-status", form: { type: "boolean" } }, ], maximumAssertionLifetimeSeconds: 300, clockSkewSeconds: 30, @@ -127,7 +127,7 @@ concept identifier. ## Progressive request and continuity receipts For the SDK's progressive API, mount a reviewed -`registry.evidence-client-profile/v1` JSON profile into the worker and configure: +``EvidenceClientProfile` (`id.registrystack.org/formats/evidence/client-profile/v1`) JSON profile into the worker and configure: ```json { "evidence": { "profilePath": "/run/evidence/client.json" } } diff --git a/packages/registry-evidence/jobs/request-evidence.js b/packages/registry-evidence/jobs/request-evidence.js index 88d830e..4f75f08 100644 --- a/packages/registry-evidence/jobs/request-evidence.js +++ b/packages/registry-evidence/jobs/request-evidence.js @@ -23,7 +23,7 @@ execute( }, ], expectedOutputs: [ - { concept: "urn:example:concept:adult-status", form: "boolean" }, + { concept: "urn:example:concept:adult-status", form: { type: "boolean" } }, ], maximumAssertionLifetimeSeconds: 300, clockSkewSeconds: 30, diff --git a/packages/registry-evidence/package.json b/packages/registry-evidence/package.json index 6a35127..bfbe39f 100644 --- a/packages/registry-evidence/package.json +++ b/packages/registry-evidence/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" + "@registrystack/client": "0.45.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-evidence/src/index.js b/packages/registry-evidence/src/index.js index 16adf39..bed3907 100644 --- a/packages/registry-evidence/src/index.js +++ b/packages/registry-evidence/src/index.js @@ -92,7 +92,7 @@ export function prepareEvidenceRequest(state, options = {}) { configuration.evidence !== undefined, )); const progressive = profileMode ? resolveInputValue(state, { - requirement: options.requirement, + handle: options.requirement, responseFormat: "signed-jws", ...(options.selectors === undefined ? {} : { selectors: options.selectors }), ...(options.subjects === undefined ? {} : { subjects: options.subjects }), @@ -187,7 +187,7 @@ export function selectSupportedValue(assertion, concept) { } function failureResult(error) { - const status = error.kind === "not_available" + const status = error.kind === "not-available" ? 422 : (Number.isSafeInteger(error.status) ? error.status : 0); const retryAfter = Number.isSafeInteger(error.retryAfterSeconds) && error.retryAfterSeconds > 0 @@ -199,7 +199,7 @@ function failureResult(error) { if (error.kind === "transport" || (error.kind === "token" && error.tokenKind === "transport")) { branch = "retryable_infrastructure"; fallbackCode = "transport.error"; - } else if (error.kind === "not_available") { + } else if (error.kind === "not-available") { branch = "evidence_not_available"; fallbackCode = "evidence_not_available"; } else if (error.kind === "token") { @@ -214,13 +214,13 @@ function failureResult(error) { } else if (error.kind === "denied" && status === 429) { branch = "retryable_infrastructure"; fallbackCode = "rate_limited"; - } else if (error.kind === "protocol" && status === 400) { + } else if (error.kind === "invalid-request" || (error.kind === "protocol" && status === 400)) { branch = "invalid_request"; fallbackCode = "malformed_request"; } else if (error.kind === "protocol" && status === 406) { branch = "response_format_not_acceptable"; fallbackCode = "response_format_not_acceptable"; - } else if (error.kind === "protocol" && status >= 500) { + } else if (error.kind === "unavailable" || (error.kind === "protocol" && status >= 500)) { branch = "retryable_infrastructure"; fallbackCode = "service_unavailable"; } @@ -306,7 +306,7 @@ function evidenceConfiguration(configuration) { token: { static: requireString(configuration.token, "configuration.token") }, }; copyOptionalConfiguration(configuration, config, "evidence_trusted_root_certificates", "trustedRootCertificates"); - copyOptionalConfiguration(configuration, config, "evidence_max_response_bytes", "maxResponseBytes"); + copyOptionalConfiguration(configuration, config, "evidence_max_response_bytes", "maximumResponseBytes"); return config; } @@ -316,14 +316,7 @@ function nativeEvidenceConfiguration(configuration, authoredNamespace) { code: "configuration.evidence.response_bound", }); } - if (configuration.maximumResponseBytes === undefined) return configuration; - const nativeConfiguration = { - ...configuration, - // The pinned 0.38 client uses the shorter spelling internally. - maxResponseBytes: configuration.maximumResponseBytes, - }; - delete nativeConfiguration.maximumResponseBytes; - return nativeConfiguration; + return configuration; } function parseConfigurationJson(value, label) { diff --git a/packages/registry-evidence/test/evidence.test.js b/packages/registry-evidence/test/evidence.test.js index 6f52461..16d71b0 100644 --- a/packages/registry-evidence/test/evidence.test.js +++ b/packages/registry-evidence/test/evidence.test.js @@ -62,6 +62,7 @@ test("requestEvidence sends and verifies through the real Registry Stack client" assert.equal(stub.requests[0].headers.accept, "application/jose+json"); assert.deepEqual(JSON.parse(stub.requests[0].body), { requestNonce: state.data.evidence.assertion.requestNonce, + expectedConfigurationRevision: spec.configurationRevision, requirement: spec.requirement, purpose: spec.purpose, subjects: [{ @@ -269,7 +270,7 @@ test("429 failures preserve the typed retry interval", async () => { "content-type": "application/problem+json", "retry-after": "30", }); - res.end(problemBody(429, "evidence.rate_limited")); + res.end(problemBody(429, "evidence.rate-limited")); }, }); @@ -460,14 +461,14 @@ test("profile requests verify first-use and matched receipts and refuse subject "POST /token": jsonRoute({ access_token: "profile-token", token_type: "Bearer", expires_in: 300 }), "GET /v1/evidence-definitions": jsonRoute({ schema: "registry.evidence-definitions/v1", assuranceProfile: "local", audience: spec.audience, issuedBy: spec.issuedBy, providedBy: spec.providedBy, - definitions: [{ handle: "adult-status", requirement: spec.requirement, configurationRevision: spec.configurationRevision, kind: "criterion", evidenceType: spec.evidenceType, purpose: spec.purpose, responseFormats: ["signed-jws"], referenceFrameworks: ["urn:example:framework:adult-status"], subjects: [{ role: "subject", cardinality: "one", selector: { profile: "civil-record-v1", valueOrigin: "request", fields: [{ type: "string", name: "record_reference", minimumBytes: 1, maximumBytes: 200 }] } }], concepts: [{ handle: "adult", concept: "urn:example:concept:adult-status", required: true, form: "boolean" }] }], + definitions: [{ handle: "adult-status", requirement: spec.requirement, configurationRevision: spec.configurationRevision, kind: "criterion", evidenceType: spec.evidenceType, purpose: spec.purpose, responseFormats: ["signed-jws"], referenceFrameworks: ["urn:example:framework:adult-status"], subjects: [{ role: "subject", cardinality: "one", selector: { profile: "civil-record-v1", valueOrigin: "request", fields: [{ type: "string", name: "record_reference", minimumBytes: 1, maximumBytes: 200 }] } }], concepts: [{ handle: "adult", concept: "urn:example:concept:adult-status", required: true, form: { type: "boolean" } }] }], }), "POST /v1/evidence": (req, res, body) => evidenceRoute(spec, signingKey, { subjects: [{ role: "subject", binding }] })(req, res, body), }); origin = stub.baseUrl.replace(/\/$/, ""); try { const profilePath = resolve(directory, "client.json"); - writeFileSync(profilePath, JSON.stringify({ schema: "registry.evidence-client-profile/v1", baseUrl: origin, clientId: "openfn-profile", privateKey: { source: "file", path: "unused-in-memory-key.jwk" }, trust: { type: "local-loopback-discovery" }, contracts: { type: "published" } }), { mode: 0o600 }); + writeFileSync(profilePath, JSON.stringify({ apiVersion: "id.registrystack.org/formats/evidence/client-profile/v1", kind: "EvidenceClientProfile", baseUrl: origin, clientId: "openfn-profile", privateKey: { type: "file", path: "unused-in-memory-key.jwk" }, trust: { type: "local-loopback-discovery" }, contracts: { type: "published" } }), { mode: 0o600 }); const configuration = { evidence: { profilePath, privateKeyJwk: { ...clientKey.privateKey.export({ format: "jwk" }), kid: clientKey.kid, alg: "ES256" } } }; const options = { requirement: "adult-status", selectors: { record_reference: { valueFrom: "person_id" } } }; const input = () => ({ configuration, data: { person_id: "person-123" } }); @@ -503,7 +504,7 @@ function requestOptions(overrides = {}) { selectorValues: { record_reference: { valueFrom: "person_id" } }, }], expectedOutputs: [ - { concept: "urn:example:concept:adult-status", form: "boolean" }, + { concept: "urn:example:concept:adult-status", form: { type: "boolean" } }, ], maximumAssertionLifetimeSeconds: 300, clockSkewSeconds: 30, @@ -615,7 +616,7 @@ function signEvidence(evidence, signingKey) { function problemBody(status, code) { const [title, detail] = { "evidence.unavailable": ["Evidence could not be produced", "evidence could not be produced for this request"], - "evidence.rate_limited": ["Evidence request rate is exhausted", "the Evidence request rate is exhausted"], + "evidence.rate-limited": ["Evidence request rate is exhausted", "the Evidence request rate is exhausted"], }[code]; return JSON.stringify({ type: `https://id.registrystack.org/problems/registry-evidence/${code.replaceAll(".", "/")}`, diff --git a/packages/registry-relay/README.md b/packages/registry-relay/README.md deleted file mode 100644 index 0c21b05..0000000 --- a/packages/registry-relay/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# OpenFn Registry Relay Adaptor - -OpenFn operations for Registry Relay 0.38.0. The adaptor uses the published -`@registrystack/client` Relay namespace, so route construction, continuations, -response validation, authentication, and protocol failures follow the maintained -Relay V2 client contract. - -Use Registry Relay for governed read-only publication. Use -`@openfn/language-registry-evidence` when a workflow needs a minimized, signed -assertion about a governed requirement. - -## Configure - -Create an OpenFn credential with: - -- `relay_base_url`: the Relay service base URL, including any deployment prefix. -- `token`: a bearer access token whose verified claims select the permitted - purpose, scopes, and access profile. - -Existing credentials may keep `relay_token` as an alias for `token`. - -The adaptor also accepts the native client configuration under -`configuration.relay`. That form supports the client's static, -`privateKeyJwt`, and exchange authorization modes. Do not combine it with the -two legacy top-level fields. - -```json -{ - "relay": { - "baseUrl": "https://relay.example.gov", - "authorization": { "static": "" }, - "maximumResponseBytes": 4194304 - } -} -``` - -The adaptor maps `maximumResponseBytes` to the pinned 0.38 client's internal -`maxResponseBytes` field. The `relay` namespace is closed, so the old spelling -and other unknown fields are refused by the configuration contract. - -## Read one record - -```js -execute( - getRecord({ - resource: "farmers", - recordIdentifier: dataValue("farmer_id"), - accessProfile: "programme-reader", - fields: ["district", "registrationStatus"], - as: "farmer", - redactDataPaths: ["farmer_id"], - }), - fn((state) => { - const record = state.data.farmer.value.data; - return { - ...state, - data: { - ...state.data, - district: record.domainData.district, - }, - }; - }), -); -``` - -`getRecord` accepts `resource`, `recordIdentifier`, optional `fields`, -`accessProfile`, `format`, and `etag`. - -## List and continue records - -```js -execute( - listRecords({ - resource: "farmers", - accessProfile: "programme-reader", - fields: ["district", "registrationStatus"], - filters: { district: "north" }, - pageSize: 50, - as: "farmer_page", - }), - continueList((state) => ({ - continuation: state.data.farmer_page.continuation, - as: "next_farmer_page", - })), -); -``` - -Continuations are opaque client values. Pass the complete returned object to -`continueList` or `continueResources`; do not copy its cursor into a new first -page request. - -## Lookup a record - -```js -lookupRecord({ - resource: "farmers", - lookup: "by-local-identifier", - selectors: { localIdentifier: dataValue("local_identifier") }, - accessProfile: "programme-reader", - as: "farmer", -}); -``` - -Lookup selector values are strings, booleans, or safe integers. The governed -lookup name and selector members must match the compiled Relay resource. - -## Metadata - -```js -execute( - getServiceMetadata({ as: "service" }), - listResources({ pageSize: 50, as: "resources" }), - getResource({ resource: "farmers", as: "farmer_resource" }), -); -``` - -Service metadata carries the published capability inventory. Resource metadata -describes one governed resource and the operations Relay actually exposes. - -## Aggregate data - -Relay 0.38 publishes aggregate data through its SDMX 2.1 surface: - -```js -queryAggregate({ - agency: "AGENCY", - resource: "FARM_ACTIVITY", - version: "1.0.0", - key: "A.NORTH", - constraints: { TIME_PERIOD: "ge:2025+le:2026" }, - offset: 0, - limit: 100, - dimensionAtObservation: "AllDimensions", - format: "json", - as: "activity", -}); -``` - -The successful result preserves `body` as UTF-8 text and `mediaType` from the -validated native response. CSV remains text; JSON is not reparsed into an -unvalidated shape. - -## Results and failures - -Every operation writes under `state.data[as]` and keeps `configuration` for -later operations in the same OpenFn job. OpenFn removes credentials at the job -boundary. - -Successful results use `branch: "succeeded"`, the native `value` or raw -`body`, `traceId`, optional `etag`, and optional `continuation`. A matching ETag -uses `branch: "not_modified"`. - -Failures expose typed, value-free facts only: - -- `invalid_request` -- `auth_failed` -- `denied` -- `not_found` -- `conflict` -- `protocol_failed` -- `retryable_infrastructure` -- `failed` - -The adaptor never returns the native error message or a Problem Details body. -Its `problem` contains only a typed client failure code, status, and retryable flag; -typed `traceId`, `retryAfterSeconds`, `transportKind`, and `tokenKind` are kept -when the client supplies them. diff --git a/packages/registry-relay/configuration-schema.json b/packages/registry-relay/configuration-schema.json deleted file mode 100644 index 1a3fc04..0000000 --- a/packages/registry-relay/configuration-schema.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "additionalProperties": true, - "properties": { - "relay_base_url": { - "title": "Registry Relay URL", - "type": "string", - "description": "Legacy Relay service base URL.", - "format": "uri", - "minLength": 1, - "examples": [ - "https://relay.example.gov" - ] - }, - "token": { - "title": "Bearer Token", - "type": "string", - "description": "Legacy static bearer token.", - "writeOnly": true, - "minLength": 1 - }, - "relay_token": { - "title": "Legacy Bearer Token Alias", - "type": "string", - "description": "Legacy alias for the static bearer token.", - "writeOnly": true, - "minLength": 1 - }, - "relay": { - "title": "Registry Relay Client Configuration", - "description": "Native Registry Stack client configuration. Credentials remain in OpenFn configuration.", - "type": "object", - "additionalProperties": false, - "required": [ - "baseUrl", - "authorization" - ], - "properties": { - "baseUrl": { - "type": "string", - "description": "Relay deployment URL, including any reverse-proxy prefix.", - "format": "uri", - "minLength": 1 - }, - "authorization": { - "writeOnly": true, - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": [ - "static" - ], - "properties": { - "static": { - "type": "string", - "description": "Bearer token without the Bearer prefix.", - "format": "password", - "minLength": 1, - "writeOnly": true - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "privateKeyJwt" - ], - "properties": { - "privateKeyJwt": { - "type": "object", - "description": "Registry Stack private-key JWT token client configuration.", - "writeOnly": true - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "exchange" - ], - "properties": { - "exchange": { - "type": "object", - "description": "Registry Stack token-exchange authorization configuration.", - "writeOnly": true - } - } - } - ] - }, - "requestTimeoutMilliseconds": { - "type": "integer", - "minimum": 1 - }, - "connectTimeoutMilliseconds": { - "type": "integer", - "minimum": 1 - }, - "maximumResponseBytes": { - "type": "integer", - "minimum": 1 - }, - "userAgent": { - "type": "string" - }, - "trustedRootCertificates": { - "type": "string", - "description": "PEM trust roots for the Relay HTTPS connection." - } - } - } - }, - "oneOf": [ - { - "required": [ - "relay" - ], - "not": { - "anyOf": [ - { - "required": [ - "relay_base_url" - ] - }, - { - "required": [ - "token" - ] - }, - { - "required": [ - "relay_token" - ] - } - ] - } - }, - { - "required": [ - "relay_base_url" - ], - "anyOf": [ - { - "required": [ - "token" - ] - }, - { - "required": [ - "relay_token" - ] - } - ], - "not": { - "required": [ - "relay" - ] - } - } - ] -} diff --git a/packages/registry-relay/jobs/read-record.js b/packages/registry-relay/jobs/read-record.js deleted file mode 100644 index f95da5c..0000000 --- a/packages/registry-relay/jobs/read-record.js +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { dataValue, execute, fn } from "@openfn/language-common"; -import { getRecord } from "../src/index.js"; - -execute( - getRecord({ - resource: "farmers", - recordIdentifier: dataValue("farmer_id"), - accessProfile: "programme-reader", - fields: ["district", "registrationStatus"], - as: "farmer", - redactDataPaths: ["farmer_id"], - }), - - fn((state) => { - const farmer = state.data.farmer.value.data; - - return { - ...state, - data: { - ...state.data, - decision_input: { - farmer_id: farmer.recordIdentifier, - district: farmer.domainData.district, - relay_trace_id: state.data.farmer.traceId, - }, - }, - }; - }), -); diff --git a/packages/registry-relay/package.json b/packages/registry-relay/package.json deleted file mode 100644 index a9a70e4..0000000 --- a/packages/registry-relay/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@openfn/language-registry-relay", - "version": "0.1.0", - "description": "OpenFn language helpers for Registry Relay.", - "license": "Apache-2.0", - "type": "module", - "main": "./src/index.js", - "exports": { - ".": "./src/index.js" - }, - "files": [ - "src", - "jobs", - "configuration-schema.json", - "README.md" - ], - "scripts": { - "test": "node --experimental-vm-modules --no-warnings --test test/relay.test.js", - "check": "node --check src/index.js && node --check src/native.cjs && node --check jobs/read-record.js && npm test", - "pack:dry-run": "npm pack --dry-run" - }, - "engines": { - "node": ">=22.12.0" - }, - "dependencies": { - "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.38.0" - }, - "devDependencies": { - "@openfn/compiler": "1.3.0", - "@openfn/runtime": "1.9.5" - } -} diff --git a/packages/registry-relay/src/index.js b/packages/registry-relay/src/index.js deleted file mode 100644 index 6774cb1..0000000 --- a/packages/registry-relay/src/index.js +++ /dev/null @@ -1,317 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { createRequire } from "node:module"; -import { types as utilTypes } from "node:util"; -export * from "@openfn/language-common"; - -const require = createRequire(import.meta.url); -const { RelayClient, RelayClientError } = require("./native.cjs"); -const UNSAFE_PATH_PARTS = new Set(["__proto__", "prototype", "constructor"]); - -class OperationFailure extends Error { - constructor(code) { - super("Registry Relay operation input is invalid"); - this.code = code; - } -} - -/** Read the governed service description and its published capability inventory. */ -export function getServiceMetadata(options = {}) { - return operation(options, (client, input) => client.serviceMetadata(input.etag)); -} - -/** Fetch one bounded page of governed resources. */ -export function listResources(options = {}) { - return operation(options, (client, input) => client.resources( - pick(input, ["pageSize"]), input.etag, - )); -} - -/** Continue a resource page with the opaque continuation returned by listResources. */ -export function continueResources(options = {}) { - return operation(options, (client, input) => client.continueResources( - requiredObject(input.continuation), input.etag, - )); -} - -/** Read one governed resource description. */ -export function getResource(options = {}) { - return operation(options, (client, input) => client.resource( - requiredString(input.resource), input.etag, - )); -} - -/** Read one Registry Record through a compiled Relay resource. */ -export function getRecord(options = {}) { - return operation(options, (client, input) => client.readRecord( - requiredString(input.resource), requiredString(input.recordIdentifier), - recordOptions(input), input.etag, - )); -} - -/** Fetch one bounded record page. Continue it explicitly with continueList. */ -export function listRecords(options = {}) { - return operation(options, (client, input) => client.listRecords( - requiredString(input.resource), - pick(input, ["pageSize", "fields", "accessProfile", "format", "filters"]), - input.etag, - )); -} - -/** Continue a record page with the opaque continuation returned by listRecords. */ -export function continueList(options = {}) { - return operation(options, (client, input) => client.continueListRecords( - requiredObject(input.continuation), input.etag, - )); -} - -/** Resolve one record through a named, compiled lookup. */ -export function lookupRecord(options = {}) { - return operation(options, (client, input) => client.lookup( - requiredString(input.resource), requiredString(input.lookup), - requiredObject(input.selectors), recordOptions(input), input.etag, - )); -} - -/** Read governed aggregate data through Relay's SDMX 2.1 data surface. */ -export function queryAggregate(options = {}) { - return operation(options, async (client, input) => rawDocument( - await client.sdmxData( - pick(input, [ - "agency", "resource", "version", "key", "constraints", "offset", "limit", - "dimensionAtObservation", "format", - ]), - input.etag, - ), - )); -} - -function operation(options, invoke) { - return async (state) => { - let input = {}; - const referencedDataPaths = new Set(); - let result; - try { - const supplied = typeof options === "function" ? options(state) : options; - if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) { - throw new OperationFailure("request.invalid"); - } - input = resolveInputValue(state, supplied, referencedDataPaths); - const client = new RelayClient(relayConfiguration(copyPlainValue( - state?.configuration, "configuration.invalid", - ))); - result = successResult(await invoke(client, input)); - } catch (error) { - result = failureResult(error); - } - - const as = typeof input.as === "string" && input.as.length > 0 ? input.as : "relay"; - const data = redactedData(state, input, referencedDataPaths); - const { response: _response, ...safeState } = state; - // OpenFn removes credentials at the job boundary. Keeping configuration here - // preserves execute/each composition across several Relay operations. - return { ...safeState, data: { ...data, [as]: result } }; - }; -} - -function successResult(outcome) { - if (outcome?.kind === "notModified") { - return pick({ branch: "not_modified", ...outcome }, ["branch", "etag", "traceId"]); - } - if (outcome?.kind !== "complete") { - return { branch: "protocol_failed", problem: { code: "relay.outcome", retryable: false } }; - } - return { - branch: "succeeded", - ...pick(outcome, ["value", "etag", "continuation", "traceId", "body", "mediaType"]), - }; -} - -function rawDocument(outcome) { - if (outcome?.kind !== "complete") return outcome; - return { - ...outcome, - body: Buffer.isBuffer(outcome.body) ? outcome.body.toString("utf8") : outcome.body, - }; -} - -function failureResult(error) { - if (error instanceof OperationFailure) { - return { branch: "invalid_request", problem: { code: error.code, retryable: false } }; - } - if (!(error instanceof RelayClientError)) { - return { branch: "failed", problem: { code: "relay.operation", retryable: false } }; - } - - const status = Number.isSafeInteger(error.status) ? error.status : 0; - let branch = "failed"; - if (error.kind === "configuration" || error.kind === "invalid_request") branch = "invalid_request"; - else if (error.kind === "problem" && status === 400) branch = "invalid_request"; - else if (error.kind === "transport" || (error.kind === "token" && error.tokenKind === "transport") - || (error.kind === "problem" && (status === 429 || status >= 500))) branch = "retryable_infrastructure"; - else if (error.kind === "token" || status === 401) branch = "auth_failed"; - else if (status === 403) branch = "denied"; - else if (status === 404 || error.kind === "not_found") branch = "not_found"; - else if (status === 409 || status === 412) branch = "conflict"; - else if (error.kind === "protocol") branch = "protocol_failed"; - - const code = safeString(error.code); - return { - branch, - problem: { - code: code?.includes(".") ? code : `relay.${error.kind}`, - status, - retryable: branch === "retryable_infrastructure", - }, - ...pick(error, ["traceId", "retryAfterSeconds", "transportKind", "tokenKind"]), - }; -} - -function relayConfiguration(configuration) { - const source = configuration?.relay ?? configuration; - if (source?.baseUrl !== undefined || source?.authorization !== undefined) return source; - return { - baseUrl: requiredString(source?.relay_base_url), - authorization: { static: requiredString(source?.token ?? source?.relay_token) }, - }; -} - -function recordOptions(input) { - return pick(input, ["fields", "accessProfile", "format"]); -} - -function requiredString(value) { - if (typeof value !== "string" || value.length === 0) throw new OperationFailure("request.required"); - return value; -} - -function requiredObject(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OperationFailure("request.required"); - } - return value; -} - -function pick(value, keys) { - return Object.fromEntries(keys.filter((key) => value?.[key] !== undefined).map((key) => [key, value[key]])); -} - -function resolveInputValue(state, value, referencedDataPaths) { - return copyPlainValue(value, "request.invalid", { - state, resolveFunctions: true, resolveReferences: true, referencedDataPaths, - }); -} - -function copyPlainValue(value, code, options = {}) { - const active = new Set(); - let nodes = 0; - let bytes = 0; - const invalid = () => { throw new OperationFailure(code); }; - const charge = (text) => { - bytes += Buffer.byteLength(text); - if (bytes > 4 * 1024 * 1024) invalid(); - return text; - }; - - function visit(item, depth) { - if (++nodes > 100000 || depth > 128) invalid(); - if (typeof item === "function") { - if (!options.resolveFunctions) invalid(); - return visit(item(options.state), depth + 1); - } - if (item === null || typeof item === "boolean") return item; - if (typeof item === "string") return charge(item); - if (typeof item === "number" && Number.isFinite(item)) return item; - if (!item || typeof item !== "object" || utilTypes.isProxy(item) || active.has(item)) invalid(); - - const array = Array.isArray(item); - const prototype = Object.getPrototypeOf(item); - if (array) { - if (!prototype || Object.getOwnPropertyDescriptor(prototype, "constructor")?.value?.name !== "Array" - || Object.getPrototypeOf(Object.getPrototypeOf(prototype)) !== null) invalid(); - } else if (prototype !== null && (Object.getPrototypeOf(prototype) !== null - || Object.getOwnPropertyDescriptor(prototype, "constructor")?.value?.name !== "Object")) invalid(); - - const keys = Reflect.ownKeys(item); - if (keys.some((key) => typeof key !== "string")) invalid(); - const descriptors = new Map(); - for (const key of keys) { - const descriptor = Object.getOwnPropertyDescriptor(item, key); - if (!descriptor || !Object.hasOwn(descriptor, "value")) invalid(); - descriptors.set(key, descriptor); - } - if (options.resolveReferences && !array && descriptors.has("valueFrom")) { - const reference = descriptors.get("valueFrom").value; - if (typeof reference !== "string") invalid(); - options.referencedDataPaths?.add(reference); - return visit(valueFromPath(options.state?.data, reference), depth + 1); - } - - active.add(item); - const result = array ? [] : {}; - let count = 0; - for (const key of keys) { - const descriptor = descriptors.get(key); - if (array && key === "length") continue; - if (!descriptor.enumerable) invalid(); - charge(key); - if (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 - || String(Number(key)) !== key || Number(key) >= item.length)) invalid(); - Object.defineProperty(result, key, { - value: visit(descriptor.value, depth + 1), - enumerable: true, - writable: true, - configurable: true, - }); - count += 1; - } - if (array && count !== item.length) invalid(); - active.delete(item); - return result; - } - - return visit(value, 0); -} - -function redactedData(state, input, referencedDataPaths) { - const data = { ...(state?.data && typeof state.data === "object" ? state.data : {}) }; - const paths = new Set(Array.isArray(input.redactDataPaths) ? input.redactDataPaths : []); - for (const path of referencedDataPaths) paths.add(path); - for (const path of paths) deleteDataPath(data, path); - return data; -} - -function valueFromPath(data, path) { - const parts = safePathParts(path); - let current = data; - for (const part of parts ?? []) { - if (!current || typeof current !== "object" || !Object.hasOwn(current, part)) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(current, part); - if (!descriptor || !Object.hasOwn(descriptor, "value")) return undefined; - current = descriptor.value; - } - return current; -} - -function deleteDataPath(data, path) { - const parts = safePathParts(path); - if (!parts?.length) return; - let current = data; - for (let index = 0; index < parts.length - 1; index += 1) { - const next = current?.[parts[index]]; - if (!next || typeof next !== "object" || Array.isArray(next)) return; - current[parts[index]] = { ...next }; - current = current[parts[index]]; - } - delete current[parts.at(-1)]; -} - -function safePathParts(path) { - if (typeof path !== "string" || path.length === 0) return undefined; - const parts = path.split(".").filter(Boolean); - return parts.some((part) => UNSAFE_PATH_PARTS.has(part)) ? undefined : parts; -} - -function safeString(value) { - return typeof value === "string" && value.length > 0 ? value : undefined; -} diff --git a/packages/registry-relay/src/native.cjs b/packages/registry-relay/src/native.cjs deleted file mode 100644 index 55b1fd8..0000000 --- a/packages/registry-relay/src/native.cjs +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// OpenFn expressions run in a VM realm. Normalize plain JSON into the SDK's -// realm while retaining its bounded, accessor-free input contract. -const { types: { isProxy } } = require("node:util"); -const { relay } = require("@registrystack/client"); -const { RelayClientError } = relay; - -function copy(value, kind = "invalid_request") { - const active = new Set(); - let nodes = 0; - let bytes = 0; - const invalid = () => { - throw new RelayClientError({ kind, message: "Registry Relay input is invalid" }); - }; - const string = (text) => { - bytes += Buffer.byteLength(text); - if (bytes > 4 * 1024 * 1024) invalid(); - return text; - }; - - function visit(item, depth) { - if (++nodes > 100000 || depth > 128) invalid(); - if (item === null || typeof item === "boolean") return item; - if (typeof item === "string") return string(item); - if (typeof item === "number" && Number.isFinite(item)) return item; - if (!item || typeof item !== "object" || isProxy(item) || active.has(item)) invalid(); - - const array = Array.isArray(item); - const prototype = Object.getPrototypeOf(item); - // Intrinsic prototypes have the same shape in each VM realm. Custom classes - // and active objects remain outside the native client's JSON boundary. - if (array) { - if (!prototype || Object.getOwnPropertyDescriptor(prototype, "constructor")?.value?.name !== "Array" - || Object.getPrototypeOf(Object.getPrototypeOf(prototype)) !== null) invalid(); - } else if (prototype !== null && (Object.getPrototypeOf(prototype) !== null - || Object.getOwnPropertyDescriptor(prototype, "constructor")?.value?.name !== "Object")) invalid(); - - active.add(item); - const result = array ? [] : {}; - let count = 0; - for (const key of Reflect.ownKeys(item)) { - if (typeof key !== "string") invalid(); - const descriptor = Object.getOwnPropertyDescriptor(item, key); - if (!descriptor || !Object.hasOwn(descriptor, "value")) invalid(); - if (array && key === "length") continue; - if (!descriptor.enumerable) invalid(); - string(key); - if (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 - || String(Number(key)) !== key || Number(key) >= item.length)) invalid(); - Object.defineProperty(result, key, { - value: visit(descriptor.value, depth + 1), - enumerable: true, - writable: true, - configurable: true, - }); - count += 1; - } - if (array && count !== item.length) invalid(); - active.delete(item); - return result; - } - - return visit(value, 0); -} - -class RelayClient extends relay.RelayClient { - constructor(config) { - const nativeConfig = copy(config, "configuration"); - if (Object.hasOwn(nativeConfig, "maxResponseBytes")) { - throw new RelayClientError({ - kind: "configuration", - message: "Registry Relay configuration uses an unsupported field", - }); - } - if (nativeConfig.maximumResponseBytes !== undefined) { - // The pinned 0.38 client uses the shorter spelling internally. - nativeConfig.maxResponseBytes = nativeConfig.maximumResponseBytes; - delete nativeConfig.maximumResponseBytes; - } - super(nativeConfig); - } -} - -for (const [method, indexes] of [ - ["resources", [0]], - ["continueResources", [0]], - ["listRecords", [1]], - ["continueListRecords", [0]], - ["readRecord", [2]], - ["lookup", [2, 3]], - ["sdmxData", [0]], -]) { - RelayClient.prototype[method] = function (...args) { - for (const index of indexes) { - if (args[index] !== undefined && args[index] !== null) args[index] = copy(args[index]); - } - return relay.RelayClient.prototype[method].apply(this, args); - }; -} - -module.exports = { RelayClient, RelayClientError }; diff --git a/packages/registry-relay/test/relay.test.js b/packages/registry-relay/test/relay.test.js deleted file mode 100644 index b257ae6..0000000 --- a/packages/registry-relay/test/relay.test.js +++ /dev/null @@ -1,451 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; -import http from "node:http"; -import { resolve } from "node:path"; -import { after, before, test } from "node:test"; -import { fileURLToPath } from "node:url"; - -import compile from "@openfn/compiler"; -import run from "@openfn/runtime"; -import { - continueList, - continueResources, - getRecord, - getResource, - getServiceMetadata, - listRecords, - listResources, - lookupRecord, - queryAggregate, -} from "../src/index.js"; - -const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; -const TRACEPARENT = `00-${TRACE_ID}-00f067aa0ba902b7-01`; -const ETAG = `"${"0123456789abcdef".repeat(4)}"`; -const requests = []; -let server; -let baseUrl; - -const packageRoot = fileURLToPath(new URL("..", import.meta.url)); -const languageCommonRoot = findDependencyRoot(packageRoot, "@openfn/language-common"); - -before(async () => { - server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const body = Buffer.concat(chunks).toString("utf8"); - requests.push({ method: request.method, url: request.url, headers: request.headers, body }); - - response.setHeader("traceparent", TRACEPARENT); - if (request.headers["if-none-match"] === ETAG) { - response.statusCode = 304; - response.setHeader("etag", ETAG); - response.end(); - return; - } - - const path = new URL(request.url, "http://relay.invalid").pathname; - if (path === "/tenant/v2/resources/farmers/records/FARMER-1001") { - json(response, recordEnvelope("FARMER-1001", "north")); - return; - } - if (path === "/tenant/v2/resources/farmers/records") { - json(response, recordCollection("FARMER-1001", "north")); - return; - } - if (path === "/tenant/v2/resources/farmers/lookups/by-local-identifier") { - json(response, recordEnvelope("FARMER-1001", "north")); - return; - } - if (path.startsWith("/tenant/sdmx/v2/data/dataflow/AGENCY/FARM_ACTIVITY/1.0.0")) { - response.statusCode = 200; - response.setHeader("content-type", "application/vnd.sdmx.data+json;version=2.1.0"); - response.end('{"dataSets":[],"structure":{"name":"Farm activity"}}'); - return; - } - problem(response, "resource.not_found", 404); - }); - await new Promise((resolveListen, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolveListen); - }); - baseUrl = `http://127.0.0.1:${server.address().port}/tenant`; -}); - -after(async () => { - await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())); -}); - -function baseState(data = {}) { - return { - data: { farmer_id: "FARMER-1001", ...data }, - configuration: { relay_base_url: baseUrl, token: "secret-token" }, - }; -} - -test("getRecord uses the 0.38 native V2 route and preserves composition credentials", async () => { - requests.length = 0; - const state = await getRecord({ - resource: "farmers", - recordIdentifier: { valueFrom: "farmer_id" }, - accessProfile: "programme-reader", - fields: ["district", "registrationStatus"], - as: "farmer", - redactDataPaths: ["farmer_id"], - })(baseState()); - - assert.equal(requests.length, 1); - const request = requests[0]; - const url = new URL(request.url, baseUrl); - assert.equal(url.pathname, "/tenant/v2/resources/farmers/records/FARMER-1001"); - assert.equal(url.searchParams.get("accessProfile"), "programme-reader"); - assert.equal(url.searchParams.get("fields"), "district,registrationStatus"); - assert.equal(request.headers.authorization, "Bearer secret-token"); - assert.deepEqual(state.data.farmer, { - branch: "succeeded", - value: recordEnvelope("FARMER-1001", "north"), - traceId: TRACE_ID, - }); - assert.equal("farmer_id" in state.data, false); - assert.equal(state.configuration.token, "secret-token"); -}); - -test("canonical response bound reaches the native client and the old authored spelling is refused", async () => { - requests.length = 0; - const configured = baseState(); - configured.configuration = { - unrelated: { enabled: true }, - relay: { - baseUrl, - authorization: { static: "secret-token" }, - maximumResponseBytes: 64, - }, - }; - const bounded = await getRecord({ resource: "farmers", recordIdentifier: "FARMER-1001" })(configured); - assert.equal(bounded.data.relay.branch, "retryable_infrastructure", JSON.stringify(bounded.data.relay)); - assert.equal(requests.length, 1); - - configured.configuration.relay = { - baseUrl, - authorization: { static: "secret-token" }, - maxResponseBytes: 64, - }; - const refused = await getRecord({ resource: "farmers", recordIdentifier: "FARMER-1001" })(configured); - assert.equal(refused.data.relay.branch, "invalid_request", JSON.stringify(refused.data.relay)); - assert.equal(requests.length, 1); - - const schema = JSON.parse( - readFileSync(new URL("../configuration-schema.json", import.meta.url), "utf8"), - ); - assert.equal(schema.properties.relay.properties.maximumResponseBytes.type, "integer"); - assert.equal(schema.properties.relay.properties.maxResponseBytes, undefined); -}); - -test("listRecords and continueList keep the native continuation closed", async () => { - requests.length = 0; - const first = await listRecords({ - resource: "farmers", - accessProfile: "programme-reader", - fields: ["district"], - filters: { district: "north" }, - pageSize: 25, - as: "page", - })(baseState()); - - const firstUrl = new URL(requests[0].url, baseUrl); - assert.equal(firstUrl.pathname, "/tenant/v2/resources/farmers/records"); - assert.equal(firstUrl.searchParams.get("pageSize"), "25"); - assert.equal(firstUrl.searchParams.get("district"), "north"); - assert.equal(first.data.page.value.items[0].domainData.district, "north"); - assert.equal(first.data.page.continuation, undefined); - - const continuation = { - route: { kind: "records", resource: "farmers" }, - cursor: "opaque_cursor-123", - format: "json", - accessProfile: "programme-reader", - }; - const next = await continueList({ continuation, etag: ETAG, as: "next" })(first); - const continuedUrl = new URL(requests.at(-1).url, baseUrl); - assert.equal(continuedUrl.pathname, "/tenant/v2/resources/farmers/records"); - assert.equal(continuedUrl.searchParams.get("cursor"), "opaque_cursor-123"); - assert.equal(continuedUrl.searchParams.get("accessProfile"), "programme-reader"); - assert.equal(continuedUrl.searchParams.has("fields"), false); - assert.deepEqual(next.data.next, { branch: "not_modified", etag: ETAG, traceId: TRACE_ID }); -}); - -test("lookupRecord sends the selector body through the native client", async () => { - requests.length = 0; - const state = await lookupRecord({ - resource: "farmers", - lookup: "by-local-identifier", - selectors: { localIdentifier: "FARMER-1001", active: true, sequence: 42 }, - accessProfile: "programme-reader", - as: "lookup", - })(baseState()); - - assert.deepEqual(JSON.parse(requests[0].body), { - selectors: { active: true, localIdentifier: "FARMER-1001", sequence: 42 }, - }); - assert.equal(state.data.lookup.branch, "succeeded"); - assert.equal(state.data.lookup.value.data.recordIdentifier, "FARMER-1001"); -}); - -test("metadata and resource continuations use only current native methods", async () => { - requests.length = 0; - let state = baseState(); - state = await getServiceMetadata({ etag: ETAG, as: "service" })(state); - state = await listResources({ pageSize: 20, etag: ETAG, as: "resources" })(state); - state = await continueResources({ - continuation: { cursor: "opaque_resource-123" }, - etag: ETAG, - as: "continued_resources", - })(state); - state = await getResource({ resource: "farmers", etag: ETAG, as: "resource" })(state); - - assert.deepEqual( - [state.data.service, state.data.resources, state.data.continued_resources, state.data.resource] - .map((result) => result.branch), - ["not_modified", "not_modified", "not_modified", "not_modified"], - ); - assert.deepEqual(requests.map((request) => new URL(request.url, baseUrl).pathname), [ - "/tenant/v2", - "/tenant/v2/resources", - "/tenant/v2/resources", - "/tenant/v2/resources/farmers", - ]); -}); - -test("queryAggregate exposes validated SDMX bytes as UTF-8 text", async () => { - requests.length = 0; - const state = await queryAggregate({ - agency: "AGENCY", - resource: "FARM_ACTIVITY", - version: "1.0.0", - key: "A.NORTH", - constraints: { TIME_PERIOD: "ge:2025+le:2026" }, - offset: 0, - limit: 100, - dimensionAtObservation: "AllDimensions", - format: "json", - as: "aggregate", - })(baseState()); - - const url = new URL(requests[0].url, baseUrl); - assert.match(url.pathname, /\/tenant\/sdmx\/v2\/data\/dataflow\/AGENCY\/FARM_ACTIVITY\/1\.0\.0\/A\.NORTH$/u); - assert.equal(url.searchParams.get("c[TIME_PERIOD]"), "ge:2025+le:2026"); - assert.equal(state.data.aggregate.branch, "succeeded"); - assert.equal(state.data.aggregate.mediaType, "application/vnd.sdmx.data+json;version=2.1.0"); - assert.equal(state.data.aggregate.body, '{"dataSets":[],"structure":{"name":"Farm activity"}}'); -}); - -test("native problems map to value-free branches", async () => { - const state = await getRecord({ - resource: "farmers", - recordIdentifier: "missing", - as: "missing", - })(baseState()); - - assert.deepEqual(state.data.missing, { - branch: "not_found", - problem: { code: "resource.not_found", status: 404, retryable: false }, - traceId: TRACE_ID, - }); - assert.equal(JSON.stringify(state).includes("The requested resource does not exist"), false); -}); - -test("missing current route arguments fail before network I/O", async () => { - requests.length = 0; - const state = await getRecord({ dataset: "old", entity: "old", id: "old" })(baseState()); - assert.deepEqual(state.data.relay, { - branch: "invalid_request", - problem: { code: "request.required", retryable: false }, - }); - assert.equal(requests.length, 0); -}); - -test("cyclic and accessor inputs fail before credentials or network are used", async () => { - requests.length = 0; - const cyclic = { resource: "farmers" }; - cyclic.filters = cyclic; - const cyclicState = await listRecords(cyclic)(baseState()); - assert.equal(cyclicState.data.relay.branch, "invalid_request"); - - let getterInvoked = false; - const accessor = { resource: "farmers" }; - Object.defineProperty(accessor, "filters", { - enumerable: true, - get() { - getterInvoked = true; - return { district: "north" }; - }, - }); - const accessorState = await listRecords(accessor)(baseState()); - assert.equal(accessorState.data.relay.branch, "invalid_request"); - assert.equal(getterInvoked, false); - - let tokenGetterInvoked = false; - const configuration = { relay_base_url: baseUrl }; - Object.defineProperty(configuration, "token", { - enumerable: true, - get() { - tokenGetterInvoked = true; - return "must-not-be-read"; - }, - }); - const configurationState = await getRecord({ - resource: "farmers", recordIdentifier: "FARMER-1001", - })({ data: {}, configuration }); - assert.equal(configurationState.data.relay.branch, "invalid_request"); - assert.equal(tokenGetterInvoked, false); - assert.equal(requests.length, 0); -}); - -test("VM-built fields, filters, and selectors cross the native JSON boundary", async () => { - const source = ` - import { dataValue, execute } from "@openfn/language-common"; - import { listRecords, lookupRecord } from "../src/index.js"; - execute( - listRecords(state => ({ - resource: "farmers", - accessProfile: "programme-reader", - fields: state.data.fields, - filters: { district: state.data.district }, - pageSize: 10, - as: "page", - })), - lookupRecord({ - resource: "farmers", - lookup: "by-local-identifier", - selectors: { localIdentifier: dataValue("farmer_id") }, - accessProfile: "programme-reader", - as: "lookup", - }), - ); - `; - const { code } = compile(source); - const result = await run( - { - workflow: { steps: [{ id: "native-json", expression: code }], start: "native-json" }, - options: { start: "native-json" }, - }, - baseState({ fields: ["district"], district: "north" }), - { - linker: { - modules: { - "@openfn/language-common": { path: languageCommonRoot }, - "../src/index.js": { path: packageRoot }, - }, - cacheKey: `openfn-relay-json-${process.pid}-${Date.now()}`, - }, - statePropsToRemove: [], - }, - ); - - assert.equal(result.errors, undefined); - assert.equal(result.data.page.branch, "succeeded"); - assert.equal(result.data.lookup.branch, "succeeded"); - assert.equal(result.data.lookup.value.data.recordIdentifier, "FARMER-1001"); -}); - -test("the compiled OpenFn job runs through the VM and the real native client", async () => { - const template = readFileSync(new URL("../jobs/read-record.js", import.meta.url), "utf8"); - const { code } = compile(template); - const result = await run( - { - workflow: { steps: [{ id: "read-record", expression: code }], start: "read-record" }, - options: { start: "read-record" }, - }, - baseState(), - { - linker: { - modules: { - "@openfn/language-common": { path: languageCommonRoot }, - "../src/index.js": { path: packageRoot }, - }, - cacheKey: `openfn-relay-037-${process.pid}-${Date.now()}`, - }, - statePropsToRemove: [], - }, - ); - - assert.equal(result.errors, undefined); - assert.equal(result.data.farmer.branch, "succeeded"); - assert.equal(result.data.decision_input.farmer_id, "FARMER-1001"); - assert.equal(result.data.decision_input.district, "north"); - assert.equal(result.data.decision_input.relay_trace_id, TRACE_ID); - assert.equal(result.configuration.token, "secret-token"); -}); - -function record(resourceId, district) { - return { - recordIdentifier: resourceId, - revisionIdentifier: "revision-1", - lifecycleState: "active", - schemaReference: "https://relay.example.invalid/schemas/farmer", - semanticModelReference: "https://relay.example.invalid/models/farmer", - authorityIdentifier: "agriculture-authority", - recordedAt: "2026-09-01T00:00:00Z", - domainData: { district, registrationStatus: "active" }, - }; -} - -function metadata() { - return { - registryIdentifier: "agriculture-registry", - datasetIdentifier: "agriculture", - entityTypeIdentifier: "farmer", - operationIdentifier: "farmer.read", - accessProfile: "programme-reader", - family: "consultation", - pattern: "retrieve", - disclosureProfile: "programme", - contractRevision: "1", - sourceRevision: { profile: "snapshot", status: "versioned", value: "1" }, - selectedFields: ["district", "registrationStatus"], - links: { - self: "https://relay.example.invalid/v2/resources/farmers/records/FARMER-1001", - context: "https://relay.example.invalid/contexts/farmer.jsonld", - schema: "https://relay.example.invalid/schemas/farmer", - semanticModel: "https://relay.example.invalid/models/farmer", - }, - }; -} - -function recordEnvelope(resourceId, district) { - return { data: record(resourceId, district), meta: metadata() }; -} - -function recordCollection(resourceId, district) { - return { items: [record(resourceId, district)], pageInfo: { nextCursor: null }, meta: metadata() }; -} - -function json(response, body) { - response.statusCode = 200; - response.setHeader("content-type", "application/json"); - response.end(JSON.stringify(body)); -} - -function problem(response, code, status) { - response.statusCode = status; - response.setHeader("content-type", "application/problem+json"); - response.end(JSON.stringify({ - type: "https://id.registrystack.org/problems/registry-relay/resource/not_found", - title: "Requested resource was not found", - status, - detail: "the requested resource was not found", - code, - traceId: TRACE_ID, - })); -} - -function findDependencyRoot(start, packageName) { - let current = resolve(start); - while (true) { - const candidate = resolve(current, "node_modules", packageName); - if (existsSync(resolve(candidate, "package.json"))) return candidate; - const parent = resolve(current, ".."); - if (parent === current) throw new Error(`dependency not found: ${packageName}`); - current = parent; - } -} diff --git a/pilot/agriculture/evidence-client.py b/pilot/agriculture/evidence-client.py index 13f0f4f..8b30fae 100644 --- a/pilot/agriculture/evidence-client.py +++ b/pilot/agriculture/evidence-client.py @@ -214,8 +214,13 @@ def prepare_client(runtime_root: Path) -> dict: "required": concept.get("required"), "form": concept.get("form"), }) + for output in expected_outputs: + # The 0.45 client takes the form as a mapping whose `type` names it. + if isinstance(output["form"], str): + output["form"] = {"type": output["form"]} if not expected_outputs or any( - output["required"] is not True or output["form"] not in {"boolean", "string", "number", "integer"} + output["required"] is not True or not isinstance(output["form"], dict) + or output["form"].get("type") not in {"boolean", "string", "number", "integer"} for output in expected_outputs ): raise RuntimeError("the governed Evidence concepts are not supported by this pilot binding") diff --git a/pilot/lightning/project.mjs b/pilot/lightning/project.mjs index 31d7a33..201681b 100644 --- a/pilot/lightning/project.mjs +++ b/pilot/lightning/project.mjs @@ -10,7 +10,7 @@ export function id(name) { } export const projectId = id('project'); -export const triggerIds = Object.fromEntries(['registration', 'correction', 'committed'].map(name => [name, id(`${name}:trigger`)])); +export const triggerIds = Object.fromEntries(['registration', 'committed'].map(name => [name, id(`${name}:trigger`)])); const checked = ` fn(state => { @@ -43,39 +43,6 @@ createRecord(state => ({ })); ${checked} fn(state => ({data: {status: 'registered', recordId: state.data.result.value.data.recordIdentifier, traceId: state.data.result.traceId}}));`; - const correction = `${validateSubmission} -createChangeRequest(state => ({ - operationIdentifier: ${literal(bindings.createCorrectionOperation)}, accessProfile: ${profile}, - entityIdentifier: 'name-correction', route: 'name-corrections', - data: {record: state.data.recordId, name: state.data.name, reason: state.data.reason, supportingReference: state.data.supportingReference}, - idempotencyKey: 'correction:' + state.data.submissionId, - as: 'result' -})); -${checked} -fn(state => ({...state, data: {...state.data, requestId: state.data.result.value.data.recordIdentifier}})); -getRecord(state => ({route: 'name-corrections', recordIdentifier: state.data.requestId, accessProfile: ${profile}, as: 'result'})); -${checked} -fn(async state => { - const current = state.data.result.value.data; - const expected = {record: state.data.recordId, name: state.data.name, reason: state.data.reason, supportingReference: state.data.supportingReference}; - if (Object.entries(expected).some(([field, value]) => typeof value !== 'string' || current.domainData[field] !== value)) { - throw new Error('Correction content does not match the submitted event'); - } - const request = current.request; - const action = request?.actions?.find(action => action.operation === 'submit_request'); - if (action) { - state = await executeLifecycleAction({ - entityIdentifier: 'name-correction', route: 'name-corrections', - recordIdentifier: state.data.requestId, accessProfile: ${profile}, - operation: 'submit_request', ifMatch: action.ifMatch, - idempotencyKey: 'correction-submit:' + state.data.submissionId, as: 'result' - })(state); - if (state.data.result.branch !== 'succeeded') throw new Error('Correction submission did not complete: ' + state.data.result.branch); - } else if (!['submitted', 'applied'].includes(request?.bregState)) { - throw new Error('Correction is not ready for submission'); - } - return {data: {status: action ? 'submitted' : 'already_submitted', requestId: state.data.requestId}}; -});`; const evidence = ` fn(state => { const event = state.data.event; @@ -138,10 +105,9 @@ fn(state => ({data: {status: state.data.status, revision: state.data.revision}, } return { id: projectId, name: 'registry-agriculture-pilot', - description: 'Synthetic governed registration, correction, and signed registration evidence.', + description: 'Synthetic governed registration and signed registration evidence.', workflows: [ workflow('registration', [['register', '@openfn/language-registry-breg@local', registration, 'breg']]), - workflow('correction', [['submit-correction', '@openfn/language-registry-breg@local', correction, 'breg']]), workflow('committed', [ ['verify-registration', '@openfn/language-registry-evidence@local', evidence, 'evidence'], ['record-update', '@openfn/language-http@local', destination, 'destination'], diff --git a/pilot/lightning/provision.mjs b/pilot/lightning/provision.mjs index 8caeb80..a9c4739 100644 --- a/pilot/lightning/provision.mjs +++ b/pilot/lightning/provision.mjs @@ -151,7 +151,6 @@ export async function run(command = 'prepare', runtime = 'pilot/agriculture/.run const bindings = { accessProfile: generated.breg.accessProfile, createFarmOperation: generated.breg.farmCreateOperation, - createCorrectionOperation: generated.breg.correctionCreateOperation, requirement: generated.evidence.requirement, registeredConcept: generated.evidence.registeredConcept, evidenceRequest: generated.evidence.request, diff --git a/pilot/test/evidence-workflow.test.js b/pilot/test/evidence-workflow.test.js index 104efd6..6b47d02 100644 --- a/pilot/test/evidence-workflow.test.js +++ b/pilot/test/evidence-workflow.test.js @@ -10,7 +10,7 @@ import { projectDocument } from '../lightning/project.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); const bindings = { - accessProfile: 'pilot', createFarmOperation: 'create', createCorrectionOperation: 'correct', + accessProfile: 'pilot', createFarmOperation: 'create', requirement: 'farm-registered', registeredConcept: 'urn:example:concept:registered', }; const revision = `sha256:${'a'.repeat(64)}`; @@ -27,7 +27,7 @@ bindings.evidenceRequest = { expectedAssuranceProfile: 'local', subjects: [{role: 'farm', selectorProfile: 'farm-id', selectorValues: {'local-identifier': {valueFrom: 'data.values.local-identifier'}}}], - expectedOutputs: [{concept: bindings.registeredConcept, form: 'boolean'}], + expectedOutputs: [{concept: bindings.registeredConcept, form: {type: 'boolean'}}], maximumAssertionLifetimeSeconds: 300, clockSkewSeconds: 30, subjectExpectations: 'acceptFirstUse', diff --git a/pilot/test/project.test.js b/pilot/test/project.test.js index 5cf5db3..acec865 100644 --- a/pilot/test/project.test.js +++ b/pilot/test/project.test.js @@ -7,13 +7,10 @@ import compile from '@openfn/compiler'; import run from '@openfn/runtime'; import * as bregAdaptor from '../../packages/registry-breg/src/index.js'; import { projectDocument, projectId, triggerIds, id } from '../lightning/project.mjs'; -import { metadata as baseMetadata, record as baseRecord, ETAG, ACTION_ETAG, ID, TRACE } from '../../packages/registry-breg/test/fixtures.js'; +import { metadata as baseMetadata, record as baseRecord, ETAG, ID, TRACE } from '../../packages/registry-breg/test/fixtures.js'; const PROFILE = 'editor'; -const REQUEST_ID = '00000000-0000-4000-8000-000000000002'; -const bindings = { accessProfile: PROFILE, createFarmOperation: 'records.farm.create', createCorrectionOperation: 'records.name-correction.create', requirement: 'urn:synthetic:registered', registeredConcept: 'urn:synthetic:registered-concept' }; -const correctionInput = { submissionId: 'correction-001', recordId: ID, name: 'Synthetic Farm Corrected', reason: 'Synthetic correction', supportingReference: 'SYNTHETIC-001' }; -const correctionData = input => ({ record: input.recordId, name: input.name, reason: input.reason, supportingReference: input.supportingReference }); +const bindings = { accessProfile: PROFILE, createFarmOperation: 'records.farm.create', requirement: 'urn:synthetic:registered', registeredConcept: 'urn:synthetic:registered-concept' }; const packagePath = fileURLToPath(new URL('../../packages/registry-breg', import.meta.url)); let cacheId = 0; @@ -27,61 +24,39 @@ async function runJob(name, baseUrl, data) { { configuration: { breg: { baseUrl, authorization: { static: 'synthetic-pilot-token' } } }, data }, { linker: { modules: { '@openfn/language-registry-breg': { path: packagePath }, '@openfn/language-common': { path: fileURLToPath(new URL('../../node_modules/@openfn/language-common', import.meta.url)) } }, cacheKey: `pilot-project-${process.pid}-${cacheId++}` } }); } - -function metadata(entity, route, fields) { +function metadata() { const value = baseMetadata(); - const fieldId = name => name.replace(/[A-Z]/g, letter => "-" + letter.toLowerCase()); - const fieldIds = Object.keys(fields).map(fieldId); - value.entities[0].id = entity; - value.entities[0].route = route; - value.entities[0].readableFields = fieldIds; - value.entities[0].schema = `/v1/schemas/${entity}`; - value.entities[0].operations.forEach(operation => { operation.accessProfile = PROFILE; }); - value.operations.forEach(operation => { - operation.id = operation.id.replace('company', entity); - operation.path = operation.path.replace('companies', route); - operation.sourceEntity = entity; - operation.responseEntity = entity; + const ids = ['local-identifier', 'name']; + const fields = [['local-identifier', 'localIdentifier', 'Local identifier'], ['name', 'name', 'Name']] + .map(([id, apiName, label]) => ({ id, apiName, label, schema: { type: 'string' }, required: true, nullable: false, readOnly: false, removable: false })); + value.recordTypes = [{ id: 'farm', datasetIdentifier: 'farms', route: 'farms', operations: [{ operation: 'create', accessProfile: PROFILE }], readableFields: ids, schema: '/v1/schemas/farm' }]; + value.operations = value.operations.filter(operation => operation.operation === 'create'); + for (const operation of value.operations) { + operation.id = 'records.farm.create'; + operation.path = '/v1/records/farms'; + operation.sourceRecordType = 'farm'; + operation.responseRecordType = 'farm'; + operation.recordTypeLabel = 'Farms'; operation.accessProfile = PROFILE; - operation.titleFields = [fieldIds[0]]; - operation.fields = Object.entries(fields).map(([field, type]) => ({ id: fieldId(field), apiName: field, label: field, schema: { type }, required: true, nullable: false, readOnly: false, removable: false })); - operation.readableFields = fieldIds; - operation.createWritableFields = operation.operation === 'create' ? fieldIds : []; - operation.patchWritableFields = operation.operation === 'patch' ? fieldIds : []; - }); + operation.titleFields = [ids[0]]; + operation.fields = fields; + operation.readableFields = ids; + operation.createWritableFields = ids; + } return value; } -function record(entity, recordId, data, lifecycleState, reviewRejected = false) { - const value = baseRecord(Boolean(lifecycleState)); - value.data.recordIdentifier = recordId; - value.data.snapshot = `breg1_${recordId}`; +function record(data) { + const value = baseRecord(); + value.data.recordIdentifier = ID; value.data.domainData = data; - value.meta.entityTypeIdentifier = entity; - if (lifecycleState) { - value.data.request.bregState = lifecycleState; - value.data.request.actions = lifecycleState === 'draft' ? [{ operation: 'submit_request', method: 'POST', href: `/v1/records/name-corrections/${recordId}/actions/submit?accessProfile=${PROFILE}`, ifMatch: ACTION_ETAG }] : []; - if (reviewRejected) { - value.data.request.review = { - submission: { state: 'accepted', authority: 'casework', requestId: REQUEST_ID, - submissionDigest: 'sha256:' + 'a'.repeat(64), - policy: { id: 'name-correction', version: '1', digest: 'sha256:' + 'b'.repeat(64) } }, - result: { state: 'rejected', resultId: REQUEST_ID, - completedAt: '2026-09-29T00:00:00Z', availableUntil: '2026-10-06T00:00:00Z' }, - delivery: { state: 'reconciled', eventId: REQUEST_ID, receivedAt: '2026-09-29T00:00:00Z' }, - application: { mode: 'manual', state: 'awaitingReview' }, - recovery: { state: 'none' }, - }; - } - } + value.meta.recordTypeIdentifier = 'farm'; + value.meta.datasetIdentifier = 'farms'; return value; } -async function fixture({ entity = 'farm', currentState = 'draft', currentData, storedData, failStatus, reviewRejected = false } = {}) { +async function fixture({ failStatus } = {}) { const requests = []; - const route = entity === 'farm' ? 'farms' : 'name-corrections'; - const recordId = entity === 'farm' ? ID : REQUEST_ID; - const fields = entity === 'farm' ? { localIdentifier: 'string', name: 'string' } : { record: 'string', name: 'string', reason: 'string', supportingReference: 'string' }; - const metadataValue = metadata(entity, route, fields); - let stored = storedData; + const metadataValue = metadata(); + let stored; let mutationCount = 0; const server = http.createServer(async (req, res) => { let body = ''; @@ -98,27 +73,18 @@ async function fixture({ entity = 'farm', currentState = 'draft', currentData, s return; } if (req.url.includes('/v1/registry')) return res.end(JSON.stringify(metadataValue)); - if (req.url.includes('/actions/')) { - mutationCount++; - currentState = 'submitted'; - const value = record(entity, recordId, stored, 'draft'); - return res.end(JSON.stringify({ id: recordId, revision: 8, snapshot: `breg1_${recordId}`, request: { bregState: 'submitted', proposalVersion: 7, effectDigest: value.data.request.effectDigest, application: null } })); - } - res.setHeader('link', `; rel="profile", ; rel="describedby"`); + res.setHeader('link', '; rel="profile", ; rel="describedby"'); res.setHeader('etag', ETAG); - if (req.method === 'POST') { - const incoming = JSON.parse(body).data; - if (!stored) { stored = incoming; mutationCount++; } - else if (!isDeepStrictEqual(incoming, stored)) { - res.statusCode = 409; - res.setHeader('content-type', 'application/problem+json'); - return res.end(JSON.stringify({ type: 'about:blank', status: 409, code: 'mutation.conflict', traceId: TRACE })); - } - res.statusCode = 201; - res.setHeader('location', `/tenant/v1/records/${route}/${recordId}`); - return res.end(JSON.stringify(record(entity, recordId, stored))); + const incoming = JSON.parse(body).data; + if (!stored) { stored = incoming; mutationCount++; } + else if (!isDeepStrictEqual(incoming, stored)) { + res.statusCode = 409; + res.setHeader('content-type', 'application/problem+json'); + return res.end(JSON.stringify({ type: 'about:blank', status: 409, code: 'mutation.conflict', traceId: TRACE })); } - return res.end(JSON.stringify(record(entity, recordId, currentData ?? stored, currentState, reviewRejected))); + res.statusCode = 201; + res.setHeader('location', `/tenant/v1/records/farms/${ID}`); + return res.end(JSON.stringify(record(stored))); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); return { requests, mutationCount: () => mutationCount, baseUrl: `http://127.0.0.1:${server.address().port}/tenant`, close: () => new Promise(resolve => server.close(resolve)) }; @@ -175,64 +141,6 @@ test('compiled registration invokes the native metadata-selected create and emit } finally { await stub.close(); } }); -test('compiled correction creates a draft, reads its action and submits with a separate idempotency key', async () => { - const stub = await fixture({ entity: 'name-correction' }); - try { - const result = await runJob('correction', stub.baseUrl, correctionInput); - assert.equal(result.errors, undefined, JSON.stringify(result)); - assert.deepEqual(result.data, { status: 'submitted', requestId: REQUEST_ID }); - assertClean(result); - const writes = stub.requests.filter(request => request.method === 'POST'); - assert.equal(writes.length, 2); - assert.deepEqual(JSON.parse(writes[0].body), { data: correctionData(correctionInput) }); - assert.equal(writes[0].headers['idempotency-key'], 'correction:correction-001'); - assert.equal(writes[1].headers['idempotency-key'], 'correction-submit:correction-001'); - assert.equal(writes[1].headers['if-match'], ACTION_ETAG); - assert.deepEqual(JSON.parse(writes[1].body), {}); - } finally { await stub.close(); } -}); - -test('same correction replay reconciles current state without duplicate lifecycle mutation', async () => { - const stub = await fixture({ entity: 'name-correction' }); - try { - const first = await runJob('correction', stub.baseUrl, correctionInput); - const replay = await runJob('correction', stub.baseUrl, correctionInput); - assert.equal(first.errors, undefined, JSON.stringify(first)); - assert.equal(replay.errors, undefined, JSON.stringify(replay)); - assert.deepEqual(replay.data, { status: 'already_submitted', requestId: REQUEST_ID }); - assert.equal(stub.mutationCount(), 2); - assert.equal(stub.requests.filter(request => request.url.includes('/actions/')).length, 1); - assertClean(replay); - } finally { await stub.close(); } -}); - -test('changed correction content is refused before already-submitted classification or action execution', async () => { - for (const currentState of ['draft', 'submitted']) { - const stub = await fixture({ entity: 'name-correction', currentState, currentData: { ...correctionData(correctionInput), name: 'A different correction' } }); - try { - const result = await runJob('correction', stub.baseUrl, correctionInput); - assert.ok(result.errors, JSON.stringify(result)); - assert.match(result.errors.pilot.message, /Correction content does not match/); - assert.equal(stub.requests.length, 3); - assert.equal(stub.requests.some(request => request.url.includes('/actions/')), false); - assertClean(result); - } finally { await stub.close(); } - } -}); - -test('unknown request states and native failures stop dependent mutations and retain safe failures', async () => { - for (const options of [{ entity: 'name-correction', currentState: 'cancelled' }, { entity: 'name-correction', failStatus: 403 }]) { - const stub = await fixture(options); - try { - const result = await runJob('correction', stub.baseUrl, correctionInput); - assert.ok(result.errors, JSON.stringify(result)); - assert.match(result.errors.pilot.message, options.failStatus ? /Registry operation did not complete: denied/ : /Correction is not ready for submission/); - assert.equal(stub.requests.some(request => request.url.includes('/actions/')), false); - assertClean(result); - } finally { await stub.close(); } - } -}); - test('unstable submission identifiers fail before any HTTP exchange', async () => { const stub = await fixture(); try { @@ -263,22 +171,3 @@ test('registration replay reuses the durable key and a changed payload conflicts assertClean(changed); } finally { await stub.close(); } }); - -test('a rejected correction replay returns its existing request without submission or mutation', async () => { - // Review decisions live in Casework. BREG remains submitted after rejection. - const stub = await fixture({ entity: 'name-correction', currentState: 'submitted', reviewRejected: true, storedData: correctionData(correctionInput) }); - try { - for (let retry = 0; retry < 2; retry++) { - const result = await runJob('correction', stub.baseUrl, correctionInput); - assert.equal(result.errors, undefined, JSON.stringify(result)); - assert.deepEqual(result.data, { status: 'already_submitted', requestId: REQUEST_ID }); - assertClean(result); - } - assert.equal(stub.mutationCount(), 0); - assert.equal(stub.requests.some(request => request.url.includes('/actions/')), false); - const replays = stub.requests.filter(request => request.method === 'POST'); - assert.equal(replays.length, 2); - assert.ok(replays.every(request => request.headers['idempotency-key'] === 'correction:correction-001')); - for (const request of replays) assert.deepEqual(JSON.parse(request.body).data, correctionData(correctionInput)); - } finally { await stub.close(); } -}); diff --git a/pilot/test/provision.test.js b/pilot/test/provision.test.js index 0592bb6..550b7aa 100644 --- a/pilot/test/provision.test.js +++ b/pilot/test/provision.test.js @@ -20,7 +20,7 @@ async function fixture(t) { await privateFile(join(files, 'breg-credential.json'), JSON.stringify({breg: {synthetic: 'private-breg'}})); await privateFile(join(files, 'evidence-credential.json'), JSON.stringify({evidence: {profilePath: '/synthetic/profile.json'}})); await privateFile(join(root, 'workflow-bindings.json'), JSON.stringify({ - breg: {accessProfile: 'synthetic', farmCreateOperation: 'records.farm.create', correctionCreateOperation: 'records.name-correction.create'}, + breg: {accessProfile: 'synthetic', farmCreateOperation: 'records.farm.create'}, evidence: {requirement: 'holding-registered', registeredConcept: 'registered'}, })); return {root, files, secrets}; @@ -197,8 +197,8 @@ test('enable proves anonymous and cross-key rejection before enabling any workfl await run('enable', fixture.root, fixture); assert.equal(fixture.enabled(), true); assert.deepEqual(fixture.probes.filter(probe => !probe.key).map(probe => probe.trigger).sort(), Object.values(triggerIds).sort()); - assert.equal(fixture.probes.filter(probe => probe.key && !probe.isOwn).length, 3); - assert.equal(fixture.probes.filter(probe => probe.isOwn).length, 3); + assert.equal(fixture.probes.filter(probe => probe.key && !probe.isOwn).length, 2); + assert.equal(fixture.probes.filter(probe => probe.isOwn).length, 2); }); for (const [name, trigger] of Object.entries(triggerIds)) {