From 37803f19cd9e157938c1fe55c4958f5a8fed3674 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 22:13:05 +0700 Subject: [PATCH 1/3] feat(adaptors): integrate current Registry clients and Casework intake Signed-off-by: Jeremi Joslin --- README.md | 4 +- package-lock.json | 60 ++- package.json | 5 +- packages/registry-breg/README.md | 7 +- .../registry-breg/configuration-schema.json | 5 +- packages/registry-breg/package.json | 2 +- packages/registry-casework/README.md | 124 +++++ .../configuration-schema.json | 71 +++ .../registry-casework/jobs/create-request.js | 14 + .../jobs/poll-terminal-results.js | 12 + packages/registry-casework/package.json | 33 ++ packages/registry-casework/src/index.js | 36 ++ packages/registry-casework/src/operations.js | 439 ++++++++++++++++++ .../registry-casework/test/casework.test.js | 425 +++++++++++++++++ .../test/multi-adaptor-job.js | 9 + .../registry-casework/test/native.test.js | 87 ++++ packages/registry-evidence/README.md | 13 +- .../configuration-schema.json | 13 + packages/registry-evidence/package.json | 2 +- packages/registry-evidence/src/index.js | 8 +- .../registry-evidence/test/evidence.test.js | 2 + 21 files changed, 1341 insertions(+), 30 deletions(-) create mode 100644 packages/registry-casework/README.md create mode 100644 packages/registry-casework/configuration-schema.json create mode 100644 packages/registry-casework/jobs/create-request.js create mode 100644 packages/registry-casework/jobs/poll-terminal-results.js create mode 100644 packages/registry-casework/package.json create mode 100644 packages/registry-casework/src/index.js create mode 100644 packages/registry-casework/src/operations.js create mode 100644 packages/registry-casework/test/casework.test.js create mode 100644 packages/registry-casework/test/multi-adaptor-job.js create mode 100644 packages/registry-casework/test/native.test.js diff --git a/README.md b/README.md index d648342..996575d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ an isolated local pilot. The maintained pilot uses: - [Registry Evidence adaptor](packages/registry-evidence), using the `evidence` - namespace of the published `@registrystack/client@0.27.0`. + namespace of the published `@registrystack/client@0.32.0`. - [Registry BREG adaptor](packages/registry-breg), using the same package's `breg` namespace for metadata-selected writes, exact lookups and lifecycle actions. +- [Registry Casework adaptor](packages/registry-casework), using the same package's + `casework` namespace for hosted intake, requester polling and task grants. - 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 and Lightning PostgreSQL databases, Mint, Evidence, an diff --git a/package-lock.json b/package-lock.json index 4f40804..aa94b2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -329,6 +329,10 @@ "resolved": "packages/registry-breg", "link": true }, + "node_modules/@openfn/language-registry-casework": { + "resolved": "packages/registry-casework", + "link": true + }, "node_modules/@openfn/language-registry-evidence": { "resolved": "packages/registry-evidence", "link": true @@ -379,23 +383,23 @@ } }, "node_modules/@registrystack/client": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.27.0.tgz", - "integrity": "sha512-wELi6zYe/r6VzunfgLGWUwAVPhLhtF1fPTArCuHxwdjlBfZhCMYbBh/QWv20M97NJm6hgp7f0y4mYqolXl/tvA==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@registrystack/client/-/client-0.32.0.tgz", + "integrity": "sha512-R6Ba56jfwJ0XC75jv0sonmm4JUVb4vio3QGa17MzEq6wuPc8GbcQ9ccHqisalmdmUacm8JrTRndhK7D2hYZEqw==", "license": "Apache-2.0", "engines": { "node": ">=22.12.0" }, "optionalDependencies": { - "@registrystack/client-darwin-arm64": "0.27.0", - "@registrystack/client-linux-arm64-gnu": "0.27.0", - "@registrystack/client-linux-x64-gnu": "0.27.0" + "@registrystack/client-darwin-arm64": "0.32.0", + "@registrystack/client-linux-arm64-gnu": "0.32.0", + "@registrystack/client-linux-x64-gnu": "0.32.0" } }, "node_modules/@registrystack/client-darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.27.0.tgz", - "integrity": "sha512-utqr47FzIIzAcIHq26aRgKfPdJ0zAhuDifE3iMh24BeyrD9erLQQpufFZvOLrvkNZ6ffr4RPSz5XvmhwbL4icQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-darwin-arm64/-/client-darwin-arm64-0.32.0.tgz", + "integrity": "sha512-ccxAL5O1vmEn4NAnhejEtisXsSSC4jI3hcukcHJIxN1Y1vYJocfsMKr6Y5NyT6oT7fj0iEH0HaeMiDE4VFY5aA==", "cpu": [ "arm64" ], @@ -409,12 +413,15 @@ } }, "node_modules/@registrystack/client-linux-arm64-gnu": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.27.0.tgz", - "integrity": "sha512-TNAVMleJhX9IwZExoq0OrUw/HgrRCC7ehvIgtz6fVDzOLbgDDBAf/pSujNeLipeqL74BYmy0SHRoQJ8h28Hwdw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-arm64-gnu/-/client-linux-arm64-gnu-0.32.0.tgz", + "integrity": "sha512-7Bd3nxGddN6MwxpyLLXbuvNARK2jPvAK17x+eHh5b24DKPcOeoQs4u1fCMrYs2jzEdDMQ/IS6JivlaxfGdnlPw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -425,12 +432,15 @@ } }, "node_modules/@registrystack/client-linux-x64-gnu": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.27.0.tgz", - "integrity": "sha512-80UiF58s5ydBSR1XZQNfxd5ybKQj9LlvocfzRu06qGNkyjEsV/pjhZzMktQhQl1ji83bdtPPZbmvNvej9vWZwA==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@registrystack/client-linux-x64-gnu/-/client-linux-x64-gnu-0.32.0.tgz", + "integrity": "sha512-dSN96774T7wXWSt2J49YXTVawImv1vjWKjWirM5bd/f7sExX1EkwTpxexT/6DoK+LDtMvOSTsDmrffSdxqT24A==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2947,7 +2957,23 @@ "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.27.0" + "@registrystack/client": "0.32.0" + }, + "devDependencies": { + "@openfn/compiler": "1.3.0", + "@openfn/runtime": "1.9.5" + }, + "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.32.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", @@ -2963,7 +2989,7 @@ "license": "Apache-2.0", "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.27.0" + "@registrystack/client": "0.32.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/package.json b/package.json index e02d24e..03c93a7 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,14 @@ "packages/*" ], "scripts": { - "check": "npm run check:evidence && npm run check:breg && 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: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", - "pack:dry-run": "npm run pack:dry-run --workspace @openfn/language-registry-evidence && npm run pack:dry-run --workspace @openfn/language-registry-breg" + "pack:dry-run": "npm run pack:dry-run --workspace @openfn/language-registry-evidence && npm run pack:dry-run --workspace @openfn/language-registry-breg && npm run pack:dry-run --workspace @openfn/language-registry-casework" }, "dependencies": { "@openfn/language-http": "7.3.3" diff --git a/packages/registry-breg/README.md b/packages/registry-breg/README.md index 18e7dc0..37bbae9 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.27.0`. The adaptor uses +`@registrystack/client@0.32.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. @@ -23,7 +23,10 @@ Put the native SDK configuration in `state.configuration.breg`, or directly in Alternatively use `authorization: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, audience } }`, where `clientKey` is a private JWK with `kty`, `kid`, -and `alg`. Token and Registry TLS trust roots and timeouts are independent. +and `alg`. The current client also accepts `authorization: { exchange: ... }` +for an immutable context and reviewed first-party or remote assertion source; +the native provider performs the exchange and bounds its lifetime. Token and +Registry TLS trust roots and timeouts are independent. The [configuration schema](./configuration-schema.json) lists the fields; the native SDK validates supported algorithms, bounds and URLs. Keep keys and tokens in OpenFn credentials, never event data or expressions. diff --git a/packages/registry-breg/configuration-schema.json b/packages/registry-breg/configuration-schema.json index 2592d95..5264033 100644 --- a/packages/registry-breg/configuration-schema.json +++ b/packages/registry-breg/configuration-schema.json @@ -10,7 +10,8 @@ "authorization": { "oneOf": [ { "type": "object", "additionalProperties": false, "required": ["static"], "properties": { "static": { "type": "string", "minLength": 1, "writeOnly": true, "format": "password", "description": "Bearer token without the Bearer prefix." } } }, - { "type": "object", "additionalProperties": false, "required": ["privateKeyJwt"], "properties": { "privateKeyJwt": { "$ref": "#/$defs/privateKeyJwt" } } } + { "type": "object", "additionalProperties": false, "required": ["privateKeyJwt"], "properties": { "privateKeyJwt": { "$ref": "#/$defs/privateKeyJwt" } } }, + { "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 }, @@ -29,6 +30,8 @@ "clientId": { "type": "string", "minLength": 1 }, "clientKey": { "type": "object", "writeOnly": true, "required": ["kty", "kid", "alg"], "properties": { "kty": { "type": "string" }, "kid": { "type": "string" }, "alg": { "type": "string" } }, "additionalProperties": true }, "audience": { "type": "string" }, + "resource": { "type": "string" }, + "scopes": { "type": "array", "items": { "type": "string" } }, "assertionLifetimeSeconds": { "type": "integer", "minimum": 1 }, "refreshMarginSeconds": { "type": "integer", "minimum": 0 }, "requestTimeoutMilliseconds": { "type": "integer", "minimum": 1 }, diff --git a/packages/registry-breg/package.json b/packages/registry-breg/package.json index 5e7a228..842f81c 100644 --- a/packages/registry-breg/package.json +++ b/packages/registry-breg/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@openfn/language-common": "3.3.4", - "@registrystack/client": "0.27.0" + "@registrystack/client": "0.32.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-casework/README.md b/packages/registry-casework/README.md new file mode 100644 index 0000000..5cebc98 --- /dev/null +++ b/packages/registry-casework/README.md @@ -0,0 +1,124 @@ +# Registry Casework adaptor + +This OpenFn adaptor wraps the maintained Registry Stack Casework Node client. +Requester operations create and read the caller's own hosted items, add and +read notes, cancel an item, and poll the caller's terminal feed. + +Task operations read the source inbox and current item, inspect grant templates +and grants, and explicitly approve or revoke a grant. They do not claim, +release, or decide a human work item or issue a task assertion into workflow +state. Casework enforces ownership and authorization from the token identity, +configured profile, and source profile. Workflow data cannot choose a token or +Casework profile. + +## Client packaging + +This package pins `@registrystack/client` to **0.32.0** and uses its `casework` +namespace. This adaptor has not been published. + +## Configuration + +Store the credential in OpenFn configuration: + +```json +{ + "casework": { + "baseUrl": "https://casework.example.test/tenant", + "token": "", + "profile": "requester" + } +} +``` + +For a refreshing service credential, replace `token` with +`authorization: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, resource, +scopes } }`. The adaptor uses the native Registry client token provider to +obtain a bearer token for each operation. Configure exactly one source. +Task-grant approval requires actor authorization and is never inferred from +a service credential. + +`requestTimeoutMilliseconds`, `connectTimeoutMilliseconds`, +`maxResponseBytes`, `userAgent`, and `trustedRootCertificates` pass to the +maintained client. The token and profile never enter operation output. OpenFn +keeps configuration available during a composed job and removes it from final +job output. + +## Operations + +- `createCaseworkItem` requires `kind`, `requesterReference`, `display`, and an + explicit `idempotencyKey`. +- `getCaseworkItem` requires `itemId`. +- `addCaseworkNote` requires `itemId`, `expectedRevision`, `note`, and an + explicit `idempotencyKey`. +- `listCaseworkNotes` requires `itemId`; `cursor` and `limit` are optional. +- `cancelCaseworkItem` requires `itemId`, `expectedRevision`, `reason`, and an + explicit `idempotencyKey`. +- `pollCaseworkResults` accepts an optional `cursor` and `limit`. +- `listCaseworkWorkItems` requires `sourceProfile` and an exact `query` with + `view`; `getCaseworkWorkItem`, `previewCaseworkTaskTemplates`, and + `listCaseworkTaskGrants` require `sourceProfile` and `itemId`. +- `approveCaseworkTaskGrant` additionally requires `expectedRevision`, + `templateId`, `templateVersion`, and a stable `idempotencyKey`. + `revokeCaseworkTaskGrant` requires `grantId`; `caseworkTaskGrantStatus` + accepts `grantId` without a source profile. + +Each operation makes one maintained-client call and writes a result under its +default key or the bounded `as` name. A success has +`{ branch: "succeeded", value, traceId? }`. Typed failures contain only bounded +diagnostics. Response detail, SDK messages, credentials, configuration, and +caller inputs are not copied into the result. + +Idempotency conflicts are returned as `branch: "conflict"`; the adaptor never +generates a replacement key or retries silently. Keep each key with the logical +create, note, or cancellation until its outcome is resolved. + +## Cursor expiry + +A typed `cursor.expired` response returns `branch: "cursor_expired"` without a +retry. The result identifies the explicit recovery: + +```json +{ + "recovery": { + "action": "restart_without_cursor", + "deduplicateBy": "eventId" + } +} +``` + +Restart the terminal poll without `cursor`, then deduplicate against persisted +`eventId` values. Notes use `noteId` instead. Poll within the configured +terminal retention period; an expired item is no longer available. + +## Example + +```js +import { execute } from "@openfn/language-common"; +import { createCaseworkItem, pollCaseworkResults } from "@openfn/language-registry-casework"; + +execute( + createCaseworkItem((state) => ({ + kind: state.data.caseworkKind, + requesterReference: state.data.requestReference, + display: state.data.caseworkDisplay, + idempotencyKey: state.data.createKey, + as: "createdCasework", + })), + pollCaseworkResults({ limit: 25, as: "terminalResults" }), +); +``` + +## Verification + +The package check uses fake bindings and the real native client over a local +HTTP fixture: + +```sh +npm run check --workspace @openfn/language-registry-casework +``` + +The two-adaptor CLI fixture under `test/multi-adaptor-job.js` runs with repeated +`-a @openfn/language-registry-evidence@local=/absolute/path/to/registry-evidence` +and `-a @openfn/language-registry-casework@local=/absolute/path/to/registry-casework`. +It imports `execute` from Evidence's reexport of OpenFn common, so a third +common adaptor alias is unnecessary. diff --git a/packages/registry-casework/configuration-schema.json b/packages/registry-casework/configuration-schema.json new file mode 100644 index 0000000..d226908 --- /dev/null +++ b/packages/registry-casework/configuration-schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Registry Casework Requester", + "description": "Place this object in configuration.casework. The Requester credential remains in OpenFn configuration.", + "type": "object", + "additionalProperties": false, + "required": ["baseUrl", "profile"], + "oneOf": [{ "required": ["token"] }, { "required": ["authorization"] }], + "properties": { + "baseUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "Registry Casework deployment URL, including any reverse-proxy prefix." + }, + "token": { + "type": "string", + "minLength": 1, + "maxLength": 16384, + "writeOnly": true, + "format": "password", + "description": "Requester bearer token without the Bearer prefix." + }, + "authorization": { + "type": "object", + "writeOnly": true, + "additionalProperties": false, + "required": ["privateKeyJwt"], + "properties": { + "privateKeyJwt": { + "type": "object", + "required": ["tokenEndpoint", "clientId", "clientKey", "resource", "scopes"], + "properties": { + "tokenEndpoint": { "type": "string", "format": "uri" }, + "clientId": { "type": "string" }, + "clientKey": { "type": "object", "writeOnly": true }, + "resource": { "type": "string" }, + "scopes": { "type": "array", "items": { "type": "string" } } + } + } + } + }, + "profile": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Configured Casework Requester profile bound to this credential." + }, + "requestTimeoutMilliseconds": { + "type": "integer", + "minimum": 1 + }, + "connectTimeoutMilliseconds": { + "type": "integer", + "minimum": 1 + }, + "maxResponseBytes": { + "type": "integer", + "minimum": 1 + }, + "userAgent": { + "type": "string", + "maxLength": 512 + }, + "trustedRootCertificates": { + "type": "string", + "maxLength": 262144, + "description": "PEM trust roots for the Registry Casework HTTPS connection." + } + } +} diff --git a/packages/registry-casework/jobs/create-request.js b/packages/registry-casework/jobs/create-request.js new file mode 100644 index 0000000..6a64684 --- /dev/null +++ b/packages/registry-casework/jobs/create-request.js @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// Credentials and the bound Requester profile belong only in configuration.casework. +import { execute } from "@openfn/language-common"; +import { createCaseworkItem } from "../src/index.js"; + +execute( + createCaseworkItem((state) => ({ + kind: state.data.caseworkKind, + requesterReference: state.data.requesterReference, + display: state.data.display, + idempotencyKey: state.data.createIdempotencyKey, + as: "createdCasework", + })), +); diff --git a/packages/registry-casework/jobs/poll-terminal-results.js b/packages/registry-casework/jobs/poll-terminal-results.js new file mode 100644 index 0000000..9fa0e9e --- /dev/null +++ b/packages/registry-casework/jobs/poll-terminal-results.js @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Persist nextCursor only after a succeeded page. Handle cursor_expired explicitly. +import { execute } from "@openfn/language-common"; +import { pollCaseworkResults } from "../src/index.js"; + +execute( + pollCaseworkResults((state) => ({ + cursor: state.data.caseworkTerminalCursor, + limit: 25, + as: "terminalResults", + })), +); diff --git a/packages/registry-casework/package.json b/packages/registry-casework/package.json new file mode 100644 index 0000000..7d36bb5 --- /dev/null +++ b/packages/registry-casework/package.json @@ -0,0 +1,33 @@ +{ + "name": "@openfn/language-registry-casework", + "version": "0.1.0", + "description": "OpenFn operations for Registry Casework hosted intake and task grants.", + "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/*.test.js", + "check": "node --check src/index.js && node --check src/operations.js && node --check jobs/create-request.js && node --check jobs/poll-terminal-results.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.32.0" + }, + "devDependencies": { + "@openfn/compiler": "1.3.0", + "@openfn/runtime": "1.9.5" + } +} diff --git a/packages/registry-casework/src/index.js b/packages/registry-casework/src/index.js new file mode 100644 index 0000000..c558d65 --- /dev/null +++ b/packages/registry-casework/src/index.js @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createRequire } from "node:module"; +import { createCaseworkOperations } from "./operations.js"; + +export * from "@openfn/language-common"; +export { CaseworkCallerError } from "./operations.js"; + +const require = createRequire(import.meta.url); +const operations = createCaseworkOperations(() => { + const bindings = require("@registrystack/client").casework; + if ( + !bindings || + typeof bindings.CaseworkClient !== "function" || + typeof bindings.CaseworkClientError !== "function" + ) { + throw new Error("Registry Casework client bindings are unavailable"); + } + return { ...bindings, PrivateKeyJwt: require("@registrystack/client").breg.PrivateKeyJwt, + ProviderError: require("@registrystack/client").breg.BaseRegistryClientError }; +}); + +export const { + createCaseworkItem, + getCaseworkItem, + addCaseworkNote, + listCaseworkNotes, + cancelCaseworkItem, + pollCaseworkResults, + listCaseworkWorkItems, + getCaseworkWorkItem, + previewCaseworkTaskTemplates, + listCaseworkTaskGrants, + approveCaseworkTaskGrant, + revokeCaseworkTaskGrant, + caseworkTaskGrantStatus, +} = operations; diff --git a/packages/registry-casework/src/operations.js b/packages/registry-casework/src/operations.js new file mode 100644 index 0000000..ae07371 --- /dev/null +++ b/packages/registry-casework/src/operations.js @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: Apache-2.0 + +const CLIENT_CONFIG_FIELDS = [ + "baseUrl", + "requestTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "maxResponseBytes", + "userAgent", + "trustedRootCertificates", +]; +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", +]); +const RESULT_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; +const SAFE_CODE = /^[a-z0-9][a-z0-9._-]{0,127}$/; + +export class CaseworkCallerError extends Error { + constructor(message, options = {}) { + super(message); + this.name = "CaseworkCallerError"; + this.code = options.code ?? "casework_caller.error"; + } +} + +class OperationFailure extends Error { + constructor(branch, code) { + super("Registry Casework operation did not complete"); + this.branch = branch; + this.code = code; + } +} + +/** Create the narrow Requester operation set around maintained client bindings. */ +export function createCaseworkOperations(loadBindings) { + if (typeof loadBindings !== "function") { + throw new CaseworkCallerError("Casework client loader is required", { + code: "client.loader_required", + }); + } + + return { + createCaseworkItem: (options = {}) => + operation(loadBindings, options, "caseworkCreated", null, (client, auth, input) => + client.createHostedItem( + auth.token, + auth.profile, + requiredString(input.idempotencyKey, "idempotencyKey"), + { + kind: requiredString(input.kind, "kind"), + requesterReference: requiredString( + input.requesterReference, + "requesterReference", + ), + display: requiredObject(input.display, "display"), + }, + ), + ), + + getCaseworkItem: (options = {}) => + operation(loadBindings, options, "caseworkItem", null, (client, auth, input) => + client.getHostedItem( + auth.token, + auth.profile, + requiredString(input.itemId, "itemId"), + ), + ), + + addCaseworkNote: (options = {}) => + operation(loadBindings, options, "caseworkNote", null, (client, auth, input) => + client.addHostedNote( + auth.token, + auth.profile, + requiredString(input.itemId, "itemId"), + requiredRevision(input.expectedRevision), + requiredString(input.idempotencyKey, "idempotencyKey"), + { note: requiredString(input.note, "note") }, + ), + ), + + listCaseworkNotes: (options = {}) => + operation(loadBindings, options, "caseworkNotes", "noteId", (client, auth, input) => + client.requesterHostedNotes( + auth.token, + auth.profile, + requiredString(input.itemId, "itemId"), + pageQuery(input), + ), + ), + + cancelCaseworkItem: (options = {}) => + operation( + loadBindings, + options, + "caseworkCancellation", + null, + (client, auth, input) => + client.cancelHostedItem( + auth.token, + auth.profile, + requiredString(input.itemId, "itemId"), + requiredRevision(input.expectedRevision), + requiredString(input.idempotencyKey, "idempotencyKey"), + { reason: requiredString(input.reason, "reason") }, + ), + ), + + pollCaseworkResults: (options = {}) => + operation( + loadBindings, + options, + "caseworkTerminal", + "eventId", + (client, auth, input) => + client.hostedTerminalItems( + auth.token, + auth.profile, + pageQuery(input), + ), + ), + listCaseworkWorkItems: (options = {}) => + operation(loadBindings, options, "caseworkWorkItems", null, (client, auth, input) => + client.listWorkItems(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredObject(input.query, "query"))), + getCaseworkWorkItem: (options = {}) => + operation(loadBindings, options, "caseworkWorkItem", null, (client, auth, input) => + client.getWorkItem(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredString(input.itemId, "itemId"))), + previewCaseworkTaskTemplates: (options = {}) => + operation(loadBindings, options, "caseworkTaskTemplates", null, (client, auth, input) => + client.previewTaskTemplates(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredString(input.itemId, "itemId"))), + listCaseworkTaskGrants: (options = {}) => + operation(loadBindings, options, "caseworkTaskGrants", null, (client, auth, input) => + client.listTaskGrants(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredString(input.itemId, "itemId"))), + approveCaseworkTaskGrant: (options = {}) => + operation(loadBindings, options, "caseworkTaskGrant", null, (client, auth, input) => + client.approveTaskGrant(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredString(input.itemId, "itemId"), + requiredRevision(input.expectedRevision), requiredString(input.idempotencyKey, "idempotencyKey"), + { templateId: requiredString(input.templateId, "templateId"), templateVersion: requiredString(input.templateVersion, "templateVersion") })), + revokeCaseworkTaskGrant: (options = {}) => + operation(loadBindings, options, "caseworkTaskRevocation", null, (client, auth, input) => + client.revokeTaskGrant(auth.token, auth.profile, requiredString(input.sourceProfile, "sourceProfile"), requiredString(input.itemId, "itemId"), requiredString(input.grantId, "grantId"))), + caseworkTaskGrantStatus: (options = {}) => + operation(loadBindings, options, "caseworkTaskStatus", null, (client, auth, input) => + client.taskGrantStatus(auth.token, requiredString(input.grantId, "grantId"))), + }; +} + +function operation(loadBindings, options, defaultName, deduplicateBy, invoke) { + return async (state) => { + let resultName = defaultName; + let ClientError; + let ProviderError; + let result; + try { + const supplied = typeof options === "function" ? options(state) : options; + if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) { + throw new OperationFailure("invalid_request", "request.invalid"); + } + const input = Object.fromEntries( + Object.entries(supplied).map(([key, value]) => [ + key, + typeof value === "function" ? value(state) : value, + ]), + ); + resultName = requestedResultName(input.as, defaultName); + const { CaseworkClient, CaseworkClientError, ProviderError: TokenError } = loadBindings(); + ClientError = CaseworkClientError; + ProviderError = TokenError; + const configuration = caseworkConfiguration(state); + validateInputs(defaultName, input); + const auth = await requesterAuthority(configuration, loadBindings); + const client = new CaseworkClient( + pick(configuration, CLIENT_CONFIG_FIELDS), + ); + const outcome = await invoke(client, auth, input); + result = { + branch: "succeeded", + value: outcome.value, + ...(safeIdentifier(outcome.traceId) ? { traceId: outcome.traceId } : {}), + }; + } catch (error) { + result = failure(error, ClientError, ProviderError, deduplicateBy); + } + + return { + ...state, + data: { + ...dataObject(state), + [resultName]: result, + }, + }; + }; +} + +function validateInputs(name, input) { + if (["caseworkCreated", "caseworkNote", "caseworkCancellation", "caseworkTaskGrant"].includes(name)) { + requiredString(input.idempotencyKey, "idempotencyKey"); + } + if (["caseworkItem", "caseworkNote", "caseworkNotes", "caseworkCancellation", "caseworkWorkItem", "caseworkTaskTemplates", + "caseworkTaskGrants", "caseworkTaskGrant", "caseworkTaskRevocation"].includes(name)) requiredString(input.itemId, "itemId"); + if (["caseworkWorkItems", "caseworkWorkItem", "caseworkTaskTemplates", "caseworkTaskGrants", "caseworkTaskGrant", + "caseworkTaskRevocation"].includes(name)) requiredString(input.sourceProfile, "sourceProfile"); + if (["caseworkNote", "caseworkCancellation", "caseworkTaskGrant"].includes(name)) requiredRevision(input.expectedRevision); + if (name === "caseworkCreated") { + requiredString(input.kind, "kind"); + requiredString(input.requesterReference, "requesterReference"); + requiredObject(input.display, "display"); + } + if (name === "caseworkNote") requiredString(input.note, "note"); + if (name === "caseworkCancellation") requiredString(input.reason, "reason"); + if (name === "caseworkWorkItems") requiredObject(input.query, "query"); + if (name === "caseworkTaskGrant") { + requiredString(input.templateId, "templateId"); + requiredString(input.templateVersion, "templateVersion"); + } + if (name === "caseworkTaskRevocation" || name === "caseworkTaskStatus") requiredString(input.grantId, "grantId"); + if (["caseworkNotes", "caseworkTerminal"].includes(name)) pageQuery(input); +} + +function failure(error, ClientError, ProviderError, deduplicateBy) { + if (error instanceof OperationFailure) { + return { + branch: error.branch, + problem: { code: error.code, retryable: false }, + }; + } + if (ProviderError && error instanceof ProviderError) { + const retryable = error.kind === "transport" || error.tokenKind === "transport"; + const branch = error.kind === "configuration" || error.kind === "invalid_request" ? "invalid_request" + : retryable ? "retryable_infrastructure" : "authentication_failed"; + return { branch, problem: { code: "casework.token", retryable } }; + } + if (!ClientError || !(error instanceof ClientError)) { + return { + branch: "failed", + problem: { code: "operation.failed", retryable: false }, + }; + } + + const status = Number.isSafeInteger(error.status) ? error.status : 0; + const code = safeProblemCode(error.code) ?? `casework.${error.kind}`; + let branch = "failed"; + if (code === "cursor.expired") branch = "cursor_expired"; + else if (error.kind === "configuration" || error.kind === "invalid_request") + branch = "invalid_request"; + else if (error.kind === "transport" || status === 429 || status >= 500) + branch = "retryable_infrastructure"; + else if (status === 401 || code === "authentication.refused") + branch = "authentication_failed"; + else if (status === 403) branch = "not_authorized"; + else if (status === 404) branch = "not_found"; + else if (status === 409 || status === 412) branch = "conflict"; + else if (status === 400 || status === 422) branch = "invalid_request"; + else if (error.kind === "protocol") branch = "protocol_failed"; + + const validation = safeValidation(error.validation); + return { + branch, + problem: { + code, + status, + retryable: branch === "retryable_infrastructure", + }, + ...(safeIdentifier(error.traceId) ? { traceId: error.traceId } : {}), + ...(safeProblemCode(error.transportKind) + ? { transportKind: error.transportKind } + : {}), + ...(safeProblemCode(error.protocolFailure) + ? { protocolFailure: error.protocolFailure } + : {}), + ...(validation ? { validation } : {}), + ...(branch === "cursor_expired" && deduplicateBy + ? { + recovery: { + action: "restart_without_cursor", + deduplicateBy, + }, + } + : {}), + }; +} + +function caseworkConfiguration(state) { + const root = + state?.configuration && + typeof state.configuration === "object" && + !Array.isArray(state.configuration) + ? state.configuration + : {}; + const configuration = root.casework ?? root; + if ( + !configuration || + typeof configuration !== "object" || + Array.isArray(configuration) + ) { + throw new OperationFailure("invalid_request", "configuration.invalid"); + } + boundedString(configuration.baseUrl, "configuration.casework.baseUrl", 2048); + if (configuration.authorization !== undefined && (!configuration.authorization || typeof configuration.authorization !== "object" + || Array.isArray(configuration.authorization) || Object.keys(configuration.authorization).length !== 1 + || configuration.authorization.privateKeyJwt === undefined)) { + throw new OperationFailure("invalid_request", "configuration.authentication"); + } + if ((configuration.token === undefined) === (configuration.authorization === undefined)) { + throw new OperationFailure("invalid_request", "configuration.authentication"); + } + if (configuration.token !== undefined) boundedString(configuration.token, "configuration.casework.token", 16_384); + boundedString(configuration.profile, "configuration.casework.profile", 128); + for (const field of [ + "requestTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "maxResponseBytes", + ]) { + if (configuration[field] !== undefined) { + requiredPositiveInteger(configuration[field], `configuration.casework.${field}`); + } + } + if (configuration.userAgent !== undefined) { + boundedString(configuration.userAgent, "configuration.casework.userAgent", 512); + } + if (configuration.trustedRootCertificates !== undefined) { + boundedString( + configuration.trustedRootCertificates, + "configuration.casework.trustedRootCertificates", + 262_144, + ); + } + return configuration; +} + +async function requesterAuthority(configuration, loadBindings) { + if (configuration.authorization?.privateKeyJwt !== undefined) { + const { PrivateKeyJwt } = loadBindings(); + return { token: await new PrivateKeyJwt(configuration.authorization.privateKeyJwt).bearerToken(), profile: configuration.profile }; + } + return { + token: configuration.token, + profile: configuration.profile, + }; +} + +function pageQuery(input) { + return { + ...(input.cursor === undefined + ? {} + : { cursor: requiredString(input.cursor, "cursor") }), + ...(input.limit === undefined + ? {} + : { limit: requiredPositiveInteger(input.limit, "limit") }), + }; +} + +function requiredString(value, label) { + if (typeof value !== "string" || !value.length) { + throw new OperationFailure("invalid_request", `${label}.required`); + } + return value; +} + +function boundedString(value, label, maximumLength) { + requiredString(value, label); + if (value.length > maximumLength) { + throw new OperationFailure("invalid_request", `${label}.too_long`); + } + return value; +} + +function requiredObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new OperationFailure("invalid_request", `${label}.object_required`); + } + return value; +} + +function requiredRevision(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new OperationFailure( + "invalid_request", + "expectedRevision.safe_integer_required", + ); + } + return value; +} + +function requiredPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new OperationFailure("invalid_request", `${label}.positive_integer_required`); + } + return value; +} + +function requestedResultName(value, fallback) { + if (value === undefined) return fallback; + if (typeof value !== "string" || !RESULT_NAME.test(value)) { + throw new OperationFailure("invalid_request", "result_name.invalid"); + } + return value; +} + +function safeProblemCode(value) { + return typeof value === "string" && SAFE_CODE.test(value) ? value : undefined; +} + +function safeIdentifier(value) { + return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); +} + +function safeValidation(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + if ( + typeof value.path !== "string" || + value.path.length > 256 || + !/^[A-Za-z0-9_./~:-]*$/.test(value.path) || + !SAFE_VALIDATION_REASONS.has(value.reason) + ) { + return undefined; + } + return { path: value.path, reason: value.reason }; +} + +function pick(value, keys) { + return Object.fromEntries( + keys + .filter((key) => value[key] !== undefined) + .map((key) => [key, value[key]]), + ); +} + +function dataObject(state) { + return state?.data && typeof state.data === "object" && !Array.isArray(state.data) + ? state.data + : {}; +} diff --git a/packages/registry-casework/test/casework.test.js b/packages/registry-casework/test/casework.test.js new file mode 100644 index 0000000..d2a57cb --- /dev/null +++ b/packages/registry-casework/test/casework.test.js @@ -0,0 +1,425 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import * as adaptor from "../src/index.js"; +import { + CaseworkCallerError, + createCaseworkOperations, +} from "../src/operations.js"; + +class FakeCaseworkClientError extends Error { + constructor(fields) { + super(fields.message ?? "synthetic client failure"); + Object.assign(this, fields); + } +} + +function fakeOperations(handlers = {}) { + const calls = []; + class FakeCaseworkClient { + constructor(configuration) { + calls.push(["constructor", configuration]); + } + } + for (const method of [ + "createHostedItem", + "getHostedItem", + "addHostedNote", + "requesterHostedNotes", + "cancelHostedItem", + "hostedTerminalItems", + "listWorkItems", + "getWorkItem", + "previewTaskTemplates", + "listTaskGrants", + "approveTaskGrant", + "revokeTaskGrant", + "taskGrantStatus", + ]) { + FakeCaseworkClient.prototype[method] = async function (...args) { + calls.push([method, ...args]); + if (handlers[method]) return handlers[method](...args); + return { + kind: "complete", + value: { method, revision: 2, items: [] }, + traceId: "trace-synthetic-1", + }; + }; + } + return { + calls, + operations: createCaseworkOperations(() => ({ + CaseworkClient: FakeCaseworkClient, + CaseworkClientError: FakeCaseworkClientError, + })), + }; +} + +function state() { + return { + configuration: { + casework: { + baseUrl: "https://casework.example.test/tenant", + token: "synthetic-requester-secret", + profile: "requester", + maxResponseBytes: 1_000_000, + }, + }, + data: { input: "retained" }, + }; +} + +test("adaptor reexports common operations used by Lightning autoimports", () => { + assert.equal(typeof adaptor.fn, "function"); + assert.equal(typeof adaptor.execute, "function"); + for (const operation of [ + "createCaseworkItem", + "getCaseworkItem", + "addCaseworkNote", + "listCaseworkNotes", + "cancelCaseworkItem", + "pollCaseworkResults", + ]) { + assert.equal(typeof adaptor[operation], "function"); + } + for (const forbidden of [ + "claimWorkItem", + "releaseWorkItem", + "decideWorkItem", + "hostedAccountabilityRecord", + "listWorkItems", + ]) { + assert.equal(forbidden in adaptor, false); + } + assert.throws(() => createCaseworkOperations(), CaseworkCallerError); +}); + +test("Requester operations preserve exact keys, revisions and maintained method shapes", async () => { + const { calls, operations } = fakeOperations(); + const initial = state(); + let result = await operations.createCaseworkItem((current) => ({ + kind: "decision", + requesterReference: "batch-0042", + display: { summary: current.data.input }, + idempotencyKey: "create-key-exact-001", + as: "created", + }))(initial); + result = await operations.getCaseworkItem({ itemId: "item-1", as: "read" })(result); + result = await operations.addCaseworkNote({ + itemId: "item-1", + expectedRevision: 7, + idempotencyKey: "note-key-exact-002", + note: "Requester context.", + as: "noted", + })(result); + result = await operations.listCaseworkNotes({ + itemId: "item-1", + cursor: "notes-cursor-1", + limit: 12, + as: "notes", + })(result); + result = await operations.cancelCaseworkItem({ + itemId: "item-1", + expectedRevision: 8, + idempotencyKey: "cancel-key-exact-003", + reason: "No longer required.", + as: "cancelled", + })(result); + result = await operations.pollCaseworkResults({ + cursor: "terminal-cursor-1", + limit: 25, + as: "terminal", + })(result); + + assert.deepEqual(calls, [ + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + [ + "createHostedItem", + "synthetic-requester-secret", + "requester", + "create-key-exact-001", + { + kind: "decision", + requesterReference: "batch-0042", + display: { summary: "retained" }, + }, + ], + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + ["getHostedItem", "synthetic-requester-secret", "requester", "item-1"], + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + [ + "addHostedNote", + "synthetic-requester-secret", + "requester", + "item-1", + 7, + "note-key-exact-002", + { note: "Requester context." }, + ], + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + [ + "requesterHostedNotes", + "synthetic-requester-secret", + "requester", + "item-1", + { cursor: "notes-cursor-1", limit: 12 }, + ], + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + [ + "cancelHostedItem", + "synthetic-requester-secret", + "requester", + "item-1", + 8, + "cancel-key-exact-003", + { reason: "No longer required." }, + ], + [ + "constructor", + { + baseUrl: "https://casework.example.test/tenant", + maxResponseBytes: 1_000_000, + }, + ], + [ + "hostedTerminalItems", + "synthetic-requester-secret", + "requester", + { cursor: "terminal-cursor-1", limit: 25 }, + ], + ]); + assert.equal(result.configuration, initial.configuration); + assert.equal(result.data.input, "retained"); + assert.equal(result.data.terminal.branch, "succeeded"); + assert.equal(JSON.stringify(result.data).includes("synthetic-requester-secret"), false); +}); + +test("terminal cursor expiry is typed, redacted and never retried silently", async () => { + let attempts = 0; + const { operations } = fakeOperations({ + hostedTerminalItems: (_token, _profile, query) => { + attempts += 1; + if (query.cursor) { + throw new FakeCaseworkClientError({ + kind: "problem", + code: "cursor.expired", + status: 400, + traceId: "trace-cursor-expired", + detail: "secret-response-canary", + }); + } + return { + kind: "complete", + value: { items: [{ eventId: "event-1" }], status: "complete" }, + traceId: "trace-restarted", + }; + }, + }); + + let result = await operations.pollCaseworkResults({ + cursor: "expired-cursor", + limit: 25, + })(state()); + assert.equal(attempts, 1); + assert.deepEqual(result.data.caseworkTerminal, { + branch: "cursor_expired", + problem: { + code: "cursor.expired", + status: 400, + retryable: false, + }, + traceId: "trace-cursor-expired", + recovery: { + action: "restart_without_cursor", + deduplicateBy: "eventId", + }, + }); + assert.equal(JSON.stringify(result).includes("secret-response-canary"), false); + + result = await operations.pollCaseworkResults({ limit: 25, as: "restarted" })( + result, + ); + assert.equal(attempts, 2); + assert.equal(result.data.restarted.branch, "succeeded"); + assert.equal(result.data.restarted.value.items[0].eventId, "event-1"); +}); + +test("note cursor recovery names noteId and caller mistakes make no client call", async () => { + let noteCalls = 0; + const { calls, operations } = fakeOperations({ + requesterHostedNotes: () => { + noteCalls += 1; + throw new FakeCaseworkClientError({ + kind: "problem", + code: "cursor.expired", + status: 400, + }); + }, + }); + const expired = await operations.listCaseworkNotes({ + itemId: "item-1", + cursor: "expired-notes", + })(state()); + assert.equal(noteCalls, 1); + assert.equal(expired.data.caseworkNotes.recovery.deduplicateBy, "noteId"); + + const before = calls.length; + const missingKey = await operations.createCaseworkItem({ + kind: "decision", + requesterReference: "batch-1", + display: {}, + })(state()); + assert.equal(missingKey.data.caseworkCreated.branch, "invalid_request"); + assert.equal(calls.length, before); +}); + +test("typed conflicts and validation expose only bounded diagnostics", async () => { + const { operations } = fakeOperations({ + cancelHostedItem: () => { + throw new FakeCaseworkClientError({ + kind: "problem", + code: "idempotency.key-reused", + status: 409, + detail: "secret-cancellation-canary", + message: "secret-message-canary", + validation: { path: "/reason", reason: "text_invalid" }, + }); + }, + }); + const result = await operations.cancelCaseworkItem({ + itemId: "item-1", + expectedRevision: 4, + idempotencyKey: "same-key-is-preserved", + reason: "Stop.", + })(state()); + assert.deepEqual(result.data.caseworkCancellation, { + branch: "conflict", + problem: { + code: "idempotency.key-reused", + status: 409, + retryable: false, + }, + 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); +}); + +test("package pins the published client contract", () => { + const manifest = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ); + const schema = JSON.parse( + readFileSync(new URL("../configuration-schema.json", import.meta.url), "utf8"), + ); + assert.equal(manifest.dependencies["@registrystack/client"], "0.32.0"); + assert.deepEqual(schema.required, ["baseUrl", "profile"]); + assert.equal(schema.properties.token.writeOnly, true); +}); + +test("oversized credentials are rejected without construction or disclosure", async () => { + const { calls, operations } = fakeOperations(); + const configured = state(); + configured.configuration.casework.token = "secret-canary".repeat(2_000); + const result = await operations.getCaseworkItem({ itemId: "item-1" })(configured); + assert.equal(result.data.caseworkItem.branch, "invalid_request"); + assert.equal(result.data.caseworkItem.problem.code, "configuration.casework.token.too_long"); + assert.equal(calls.length, 0); + assert.equal(JSON.stringify(result.data).includes("secret-canary"), false); +}); + +test("source task inspection and approval use the held profile, revision and key", async () => { + const { calls, operations } = fakeOperations(); + let current = state(); + current = await operations.listCaseworkWorkItems({ sourceProfile: "reviewer", query: { view: "my_teams", limit: 20 } })(current); + current = await operations.getCaseworkWorkItem({ sourceProfile: "reviewer", itemId: "item-1" })(current); + current = await operations.previewCaseworkTaskTemplates({ sourceProfile: "reviewer", itemId: "item-1" })(current); + current = await operations.listCaseworkTaskGrants({ sourceProfile: "reviewer", itemId: "item-1" })(current); + current = await operations.approveCaseworkTaskGrant({ sourceProfile: "reviewer", itemId: "item-1", expectedRevision: 7, + idempotencyKey: "approval-1", templateId: "status", templateVersion: "1" })(current); + current = await operations.revokeCaseworkTaskGrant({ sourceProfile: "reviewer", itemId: "item-1", 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", "item-1"], + ["previewTaskTemplates", "synthetic-requester-secret", "requester", "reviewer", "item-1"], + ["listTaskGrants", "synthetic-requester-secret", "requester", "reviewer", "item-1"], + ["approveTaskGrant", "synthetic-requester-secret", "requester", "reviewer", "item-1", 7, "approval-1", { templateId: "status", templateVersion: "1" }], + ["revokeTaskGrant", "synthetic-requester-secret", "requester", "reviewer", "item-1", "grant-1"], + ["taskGrantStatus", "synthetic-requester-secret", "grant-1"], + ]); + assert.equal(current.data.caseworkTaskStatus.branch, "succeeded"); + assert.equal("taskAssertion" in operations, false); +}); + +test("refreshing private-key JWT keeps minted token out of workflow data", async () => { + const calls = []; + class PrivateKeyJwt { + constructor(config) { calls.push(["provider", config]); } + async bearerToken() { calls.push(["mint"]); return "short-lived-token"; } + } + class CaseworkClient { + constructor(config) { calls.push(["client", config]); } + async getHostedItem(...args) { calls.push(["get", ...args]); return { value: { itemId: "item-1" } }; } + } + const operations = createCaseworkOperations(() => ({ CaseworkClient, CaseworkClientError: FakeCaseworkClientError, PrivateKeyJwt })); + const initial = state(); + delete initial.configuration.casework.token; + initial.configuration.casework.authorization = { privateKeyJwt: { tokenEndpoint: "https://issuer.invalid/token", clientId: "worker", + clientKey: { kty: "EC", kid: "test", alg: "ES256" }, resource: "urn:casework", scopes: ["casework:request"] } }; + const result = await operations.getCaseworkItem({ itemId: "item-1" })(initial); + assert.equal(result.data.caseworkItem.branch, "succeeded"); + assert.deepEqual(calls.at(-1), ["get", "short-lived-token", "requester", "item-1"]); + assert.equal(JSON.stringify(result.data).includes("short-lived-token"), false); + initial.configuration.casework.token = "ambiguous"; + const invalid = await operations.getCaseworkItem({ itemId: "item-1" })(initial); + assert.equal(invalid.data.caseworkItem.problem.code, "configuration.authentication"); +}); + +test("native token provider transport failure is retryable and redacted", async () => { + class ProviderError extends Error { + constructor() { super("secret-token-response"); this.kind = "token"; this.tokenKind = "transport"; } + } + class PrivateKeyJwt { async bearerToken() { throw new ProviderError(); } } + const operations = createCaseworkOperations(() => ({ CaseworkClient: class {}, CaseworkClientError: FakeCaseworkClientError, + ProviderError, PrivateKeyJwt })); + const initial = state(); + delete initial.configuration.casework.token; + initial.configuration.casework.authorization = { privateKeyJwt: { tokenEndpoint: "https://issuer.invalid/token" } }; + const result = await operations.getCaseworkItem({ itemId: "item-1" })(initial); + assert.deepEqual(result.data.caseworkItem, { + branch: "retryable_infrastructure", problem: { code: "casework.token", retryable: true }, + }); + assert.equal(JSON.stringify(result.data).includes("secret-token-response"), false); +}); diff --git a/packages/registry-casework/test/multi-adaptor-job.js b/packages/registry-casework/test/multi-adaptor-job.js new file mode 100644 index 0000000..e794ac7 --- /dev/null +++ b/packages/registry-casework/test/multi-adaptor-job.js @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// CLI fixture: prove that one job imports both maintained adaptors through their aliases. +import { execute, requestEvidence } from "@openfn/language-registry-evidence"; +import { createCaseworkItem } from "@openfn/language-registry-casework"; + +execute(async state => ({ + ...state, + data: { ...state.data, adaptorsReady: typeof requestEvidence === "function" && typeof createCaseworkItem === "function" }, +})); diff --git a/packages/registry-casework/test/native.test.js b/packages/registry-casework/test/native.test.js new file mode 100644 index 0000000..dc78eef --- /dev/null +++ b/packages/registry-casework/test/native.test.js @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { readFileSync } from "node:fs"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import compile from "@openfn/compiler"; +import run from "@openfn/runtime"; +import { createCaseworkItem, getCaseworkItem, listCaseworkWorkItems, approveCaseworkTaskGrant } from "../src/index.js"; + +test("real native Casework client creates hosted intake and preserves task authority", async (context) => { + const requests = []; + const server = http.createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.writeHead(req.method === "POST" && req.url === "/v1/hosted-items" ? 201 : 200, + { "content-type": "application/json", traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01" }); + if (req.url === "/v1/hosted-items") { + res.end(JSON.stringify({ itemId: "00000000-0000-4000-8000-000000000001", requesterReference: "event-42", + kind: "intake", version: "1", display: { reference: "event-42" }, state: "open", revision: 1, + kindPolicyDigest: `sha256:${"a".repeat(64)}`, createdAt: "2026-09-11T00:00:00Z", updatedAt: "2026-09-11T00:00:00Z" })); + } else if (req.method === "GET") { + res.end(JSON.stringify({ items: [], servedQueues: ["review"], status: "complete" })); + } else { + res.end(JSON.stringify({ id: "00000000-0000-4000-8000-000000000002", templateId: "verify", templateVersion: "1", + agent: { issuer: "https://issuer.invalid", subject: "worker" }, client: "worker", resource: "urn:evidence", scopes: [], + purpose: "verify", bounds: { type: "evidence", requirement: "status" }, expiresAt: 2000000900, invalidated: false })); + } + }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + context.after(() => new Promise(resolve => server.close(resolve))); + const state = { configuration: { casework: { baseUrl: `http://127.0.0.1:${server.address().port}/`, token: "synthetic-token", profile: "requester" } }, data: {} }; + const created = await createCaseworkItem({ kind: "intake", requesterReference: "event-42", display: { reference: "event-42" }, idempotencyKey: "event-42:create" })(state); + assert.equal(created.data.caseworkCreated.branch, "succeeded", JSON.stringify(created.data.caseworkCreated)); + assert.equal(requests[0].url, "/v1/hosted-items"); + assert.equal(requests[0].headers["idempotency-key"], "event-42:create"); + assert.equal(requests[0].headers.authorization, "Bearer synthetic-token"); + const listed = await listCaseworkWorkItems({ sourceProfile: "reviewer", query: { view: "my_teams", limit: 20 } })(created); + assert.equal(listed.data.caseworkWorkItems.branch, "succeeded", JSON.stringify(listed.data.caseworkWorkItems)); + assert.equal(requests[1].headers["registry-source-profile"], "reviewer"); + const approved = await approveCaseworkTaskGrant({ sourceProfile: "reviewer", itemId: "00000000-0000-4000-8000-000000000001", + expectedRevision: 7, idempotencyKey: "grant-42", templateId: "verify", templateVersion: "1" })(listed); + assert.equal(approved.data.caseworkTaskGrant.branch, "succeeded", JSON.stringify(approved.data.caseworkTaskGrant)); + assert.equal(requests[2].headers["if-match"], '"7"'); + assert.equal(requests[2].headers["idempotency-key"], "grant-42"); + assert.deepEqual(JSON.parse(requests[2].body), { templateId: "verify", templateVersion: "1" }); + const { code } = compile(readFileSync(new URL("../jobs/create-request.js", import.meta.url), "utf8")); + const runtime = await run({ workflow: { steps: [{ id: "hosted", expression: code }], start: "hosted" }, options: { start: "hosted" } }, + { ...state, data: { caseworkKind: "intake", requesterReference: "event-42", display: { reference: "event-42" }, createIdempotencyKey: "event-42:create" } }, + { 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: `casework-${process.pid}` } }); + assert.equal(runtime.errors, undefined, JSON.stringify(runtime.errors)); + assert.equal(runtime.data.createdCasework.branch, "succeeded", JSON.stringify(runtime.data)); + assert.equal(requests[3].headers["idempotency-key"], "event-42:create"); + assert.equal("configuration" in runtime, false); + assert.equal(JSON.stringify(runtime).includes("synthetic-token"), false); +}); + +test("Casework service credential is minted by the native provider before one read", async (context) => { + const requests = []; + const server = http.createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requests.push({ url: req.url, headers: req.headers, body }); + res.writeHead(200, { "content-type": "application/json", traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01" }); + res.end(req.url === "/token" ? JSON.stringify({ access_token: "minted-casework-token", token_type: "Bearer", expires_in: 300 }) + : JSON.stringify({ itemId: "00000000-0000-4000-8000-000000000001", requesterReference: "event-42", kind: "intake", version: "1", + display: {}, state: "open", revision: 1, kindPolicyDigest: `sha256:${"a".repeat(64)}`, + createdAt: "2026-09-11T00:00:00Z", updatedAt: "2026-09-11T00:00:00Z" })); + }); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + context.after(() => new Promise(resolve => server.close(resolve))); + const baseUrl = `http://127.0.0.1:${server.address().port}/`; + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const clientKey = { ...privateKey.export({ format: "jwk" }), kid: "synthetic-key", alg: "ES256" }; + const state = { configuration: { casework: { baseUrl, profile: "requester", authorization: { privateKeyJwt: { + tokenEndpoint: `${baseUrl}token`, clientId: "worker", clientKey, resource: baseUrl, scopes: ["casework:read"] } } } }, data: {} }; + const invalid = await createCaseworkItem({ kind: "intake", display: {} })(state); + assert.equal(invalid.data.caseworkCreated.branch, "invalid_request"); + assert.equal(requests.length, 0); + const result = await getCaseworkItem({ itemId: "00000000-0000-4000-8000-000000000001" })(state); + assert.equal(result.data.caseworkItem.branch, "succeeded", JSON.stringify(result.data.caseworkItem)); + assert.equal(requests[0].url, "/token"); + assert.equal(requests[1].headers.authorization, "Bearer minted-casework-token"); + assert.equal(JSON.stringify(result.data).includes("minted-casework-token"), false); +}); diff --git a/packages/registry-evidence/README.md b/packages/registry-evidence/README.md index 84e05a7..21f9ff7 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.27.0** and uses its `evidence` +This package pins `@registrystack/client` to **0.32.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. @@ -146,8 +146,15 @@ A verified result includes `subject_continuity: { status, receipt }` with status `firstUse` or `matched`. Persist the opaque receipt and pass it on the next call. The native SDK validates receipt scope and subject continuity. Profile mode accepts `requirement`, `selectors` or role-mapped `subjects`, and `bindingReceipt`; -full explicit policy fields belong to the explicit API above. Both modes request -signed JWS only, retain its exact bytes, and publish no native handles. +full explicit policy fields belong to the explicit API above. The reviewed +profile closes definition-specific expectations before sending. Both modes +request signed JWS only, retain its exact bytes, and publish no native handles. +Progressive success also returns `retained_verification` as base64 of the native +verification snapshot; persist it with the signed bytes when audit retention +is required. A profile can use `authorization: { exchange: ... }` instead of +`privateKeyJwk`. The native client's remote exchange source binds the Casework +grant assertion and exchanges it without placing a short-lived assertion in +workflow data. ## Failure branches diff --git a/packages/registry-evidence/configuration-schema.json b/packages/registry-evidence/configuration-schema.json index 0f1a044..8044ce1 100644 --- a/packages/registry-evidence/configuration-schema.json +++ b/packages/registry-evidence/configuration-schema.json @@ -63,6 +63,10 @@ ] } }, + { + "required": ["baseUrl", "trustedJwks", "revokedKeyIds", "authorization"], + "not": { "anyOf": [{ "required": ["token"] }, { "required": ["profilePath"] }] } + }, { "required": [ "profilePath" @@ -76,6 +80,10 @@ "privateKeyJwk": { "type": "object", "writeOnly": true + }, + "authorization": { + "type": "object", + "writeOnly": true } } } @@ -152,6 +160,11 @@ } } ] + }, + "authorization": { + "type": "object", + "writeOnly": true, + "description": "Native Registry client exchange authorization; mutually exclusive with token." } } } diff --git a/packages/registry-evidence/package.json b/packages/registry-evidence/package.json index 167fc89..f003da9 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.27.0" + "@registrystack/client": "0.32.0" }, "devDependencies": { "@openfn/compiler": "1.3.0", diff --git a/packages/registry-evidence/src/index.js b/packages/registry-evidence/src/index.js index f81ba88..dbb5d51 100644 --- a/packages/registry-evidence/src/index.js +++ b/packages/registry-evidence/src/index.js @@ -84,7 +84,9 @@ export function prepareEvidenceRequest(state, options = {}) { throw new EvidenceCallerError("request options do not match the configured client mode", { code: "configuration.request_mode" }); } const client = profileMode - ? EvidenceClient.fromProfile(clientConfig.profilePath, clientConfig.privateKeyJwk) + ? clientConfig.authorization === undefined + ? EvidenceClient.fromProfile(clientConfig.profilePath, clientConfig.privateKeyJwk) + : EvidenceClient.fromProfileWithAuthorization(clientConfig.profilePath, clientConfig.authorization) : new EvidenceClient(clientConfig); const progressive = profileMode ? resolveInputValue(state, { requirement: options.requirement, @@ -118,6 +120,7 @@ export async function callEvidence(state, request) { requirement: request.requirement, assertion: verified.evidence, jws: verified.assertion.toString("utf8"), + retained_verification: verified.retainedVerification.toString("base64"), subject_continuity: verified.subjectContinuity, verification: { authentic: true, currently_valid: true, policy_satisfied: true }, }); @@ -284,7 +287,8 @@ function evidenceConfiguration(configuration) { throw new EvidenceCallerError("configure evidence or legacy Evidence fields, never both", { code: "configuration.authentication" }); } const config = parseConfigurationJson(configuration.evidence, "configuration.evidence"); - if (config.profilePath !== undefined && Object.keys(config).some((key) => !["profilePath", "privateKeyJwk"].includes(key))) { + if (config.profilePath !== undefined && (Object.keys(config).some((key) => !["profilePath", "privateKeyJwk", "authorization"].includes(key)) + || (config.privateKeyJwk !== undefined && config.authorization !== undefined))) { throw new EvidenceCallerError("profilePath cannot be combined with explicit client settings", { code: "configuration.profile" }); } return config; diff --git a/packages/registry-evidence/test/evidence.test.js b/packages/registry-evidence/test/evidence.test.js index cc12ae7..8f155d2 100644 --- a/packages/registry-evidence/test/evidence.test.js +++ b/packages/registry-evidence/test/evidence.test.js @@ -429,6 +429,8 @@ test("profile requests verify first-use and matched receipts and refuse subject assert.equal(second.data.evidence.subject_continuity.status, "matched"); assert.equal(second.data.evidence.trace_id, TRACE_ID); assert.equal(typeof second.data.evidence.jws, "string"); + assert.equal(typeof second.data.evidence.retained_verification, "string"); + assert.ok(Buffer.from(second.data.evidence.retained_verification, "base64").length > 0); binding = TEMPLATE_SUBJECT_BINDING; await assert.rejects(requestEvidence({ ...options, bindingReceipt: receipt })(input()), { name: "EvidenceCallerError" }); assert.equal(stub.requests.filter(request => request.url === "/v1/evidence").length, 3); From 4d17214b84ab512e98072d159357ca90a7d64145 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 00:59:20 +0700 Subject: [PATCH 2/3] fix(casework): materialize VM nested inputs for native validation Signed-off-by: Jeremi Joslin --- packages/registry-casework/src/operations.js | 8 +++++++- .../registry-casework/test/casework.test.js | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/registry-casework/src/operations.js b/packages/registry-casework/src/operations.js index ae07371..c1cac68 100644 --- a/packages/registry-casework/src/operations.js +++ b/packages/registry-casework/src/operations.js @@ -375,7 +375,13 @@ function requiredObject(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new OperationFailure("invalid_request", `${label}.object_required`); } - return value; + // OpenFn job literals live in a VM realm. Materialize the JSON-shaped input + // in this realm before the native facade applies its bounded JSON validator. + try { + return structuredClone(value); + } catch { + throw new OperationFailure("invalid_request", `${label}.object_required`); + } } function requiredRevision(value) { diff --git a/packages/registry-casework/test/casework.test.js b/packages/registry-casework/test/casework.test.js index d2a57cb..4a43981 100644 --- a/packages/registry-casework/test/casework.test.js +++ b/packages/registry-casework/test/casework.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import vm from "node:vm"; import * as adaptor from "../src/index.js"; import { @@ -225,6 +226,24 @@ test("Requester operations preserve exact keys, revisions and maintained method assert.equal(JSON.stringify(result.data).includes("synthetic-requester-secret"), false); }); +test("VM-authored nested display and query reach native operations as local JSON", async () => { + const { calls, operations } = fakeOperations(); + const display = vm.runInNewContext('({ summary: "Reviewed", nested: { count: 2 } })'); + const query = vm.runInNewContext('({ view: "my_teams", filters: { state: "open" } })'); + await operations.createCaseworkItem({ kind: "example", requesterReference: "source-1", + display, idempotencyKey: "effect-1" })(state()); + await operations.listCaseworkWorkItems({ sourceProfile: "reviewer", query })(state()); + const createdDisplay = calls.find(([method]) => method === "createHostedItem")[4].display; + const listedQuery = calls.find(([method]) => method === "listWorkItems")[4]; + assert.deepEqual(createdDisplay, { summary: "Reviewed", nested: { count: 2 } }); + assert.equal(Object.getPrototypeOf(createdDisplay.nested), Object.prototype); + assert.deepEqual(listedQuery, { view: "my_teams", filters: { state: "open" } }); + assert.equal(Object.getPrototypeOf(listedQuery.filters), Object.prototype); + const refused = await operations.createCaseworkItem({ kind: "example", requesterReference: "source-1", + display: new Proxy({}, {}), idempotencyKey: "effect-1" })(state()); + assert.equal(refused.data.caseworkCreated.branch, "invalid_request"); +}); + test("terminal cursor expiry is typed, redacted and never retried silently", async () => { let attempts = 0; const { operations } = fakeOperations({ From adde686f05ba1a78c4453c31a229afc3bb88d3a2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 12:40:12 +0700 Subject: [PATCH 3/3] chore(pilot): assert the pinned Registry client 0.32.0 in the worker image Signed-off-by: Jeremi Joslin --- CHANGELOG.md | 8 ++++++++ deployment/check-worker.mjs | 2 +- deployment/images.lock.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c8b33..0aca9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ - The event bridge accepts `BREG_BIND_HOST=127.0.0.1` for host-local receivers. Its existing `0.0.0.0` default remains unchanged. +- `@openfn/language-registry-casework` wraps the native Casework client for + requester operations on hosted items and task operations on source inboxes + and grants. + +### Changed + +- The Evidence, BREG and Casework adaptors pin `@registrystack/client` 0.32.0. + The pilot's Registry Stack images and bootstrap tools remain at 0.27.0. ### Removed diff --git a/deployment/check-worker.mjs b/deployment/check-worker.mjs index 153f394..bb51e46 100644 --- a/deployment/check-worker.mjs +++ b/deployment/check-worker.mjs @@ -11,7 +11,7 @@ const require = createRequire('/opt/registry-adaptors/package.json'); const client = require('@registrystack/client'); assert.equal(typeof client.evidence.EvidenceClient, 'function'); new client.breg.BaseRegistryClient({ baseUrl: 'http://127.0.0.1:8090' }); -assert.equal(JSON.parse(readFileSync('/opt/registry-adaptors/node_modules/@registrystack/client/package.json')).version, '0.27.0'); +assert.equal(JSON.parse(readFileSync('/opt/registry-adaptors/node_modules/@registrystack/client/package.json')).version, '0.32.0'); assert.ok(process.report.getReport().header.glibcVersionRuntime, 'worker must use glibc'); const { default: compile, preloadAdaptorExports } = await import('/app/packages/compiler/dist/index.js'); const { default: run } = await import('/app/packages/runtime/dist/index.js'); diff --git a/deployment/images.lock.json b/deployment/images.lock.json index 5b516fc..1b991ac 100644 --- a/deployment/images.lock.json +++ b/deployment/images.lock.json @@ -6,7 +6,7 @@ "worker": "openfn/ws-worker:v1.29.0@sha256:827f4897d120c322d977da7bc2810174332eb5ba4902fdb7279178812d787c18", "node": "node:24.19.0-bookworm@sha256:4196d66a565c6f195728d9952f161f4adfe2ad753052a08b7ec7f1c5a6bda42b", "postgres": "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", - "registryClientVersion": "0.27.0", + "registryClientVersion": "0.32.0", "toolsDebianSnapshot": "20260824T000000Z", "notes": "Released Lightning and worker images publish linux/amd64 only. The worker application is copied unchanged to the pinned glibc Node image; native optional dependencies are installed from the released worker lockfile. No arm64 worker claim is made.", "breg": "ghcr.io/registrystack/breg:v0.27.0@sha256:222e4ee3e27f7ee5f237dc06693a07773222b39ef15051abb27bac2cae632586",