From ee7ce48fb91ecf9a20f47d827f08480ae7876d73 Mon Sep 17 00:00:00 2001 From: SanctionsKit Date: Tue, 6 Oct 2026 21:34:17 -0600 Subject: [PATCH] Add n8n onboarding and supplier review workflows Add offline and synthetic sandbox workflows with explicit review, error and unavailable outcomes. Preserve event and row identity without making onboarding or supplier decisions. Validate 45 local checks, published-node contracts and retained native n8n import/export and offline execution evidence. No hosted API execution or durable external register is claimed. --- .github/workflows/n8n-supplier-review.yml | 31 ++ .github/workflows/n8n.yml | 31 ++ CONTRIBUTING.md | 15 + README.md | 8 + integrations/n8n-supplier-review/README.md | 107 ++++++ integrations/n8n-supplier-review/build.cjs | 51 +++ .../fixtures/example-register.json | 242 ++++++++++++ .../fixtures/suppliers.json | 15 + .../functions/register.cjs | 196 ++++++++++ integrations/n8n-supplier-review/package.json | 13 + .../supplier-review.offline.json | 199 ++++++++++ .../supplier-review.sandbox.json | 212 +++++++++++ .../tests/native-runtime.cjs | 91 +++++ .../tests/register.test.cjs | 197 ++++++++++ integrations/n8n/README.md | 77 ++++ integrations/n8n/build.cjs | 47 +++ integrations/n8n/functions/review.cjs | 95 +++++ .../n8n/onboarding-review.offline.json | 325 +++++++++++++++++ .../n8n/onboarding-review.sandbox.json | 343 ++++++++++++++++++ integrations/n8n/package.json | 11 + .../n8n/tests/fixtures/base-evidence.json | 142 ++++++++ integrations/n8n/tests/fixtures/cases.json | 12 + integrations/n8n/tests/fixtures/responses.cjs | 23 ++ integrations/n8n/tests/native-runtime.cjs | 116 ++++++ integrations/n8n/tests/review.test.cjs | 174 +++++++++ 25 files changed, 2773 insertions(+) create mode 100644 .github/workflows/n8n-supplier-review.yml create mode 100644 .github/workflows/n8n.yml create mode 100644 integrations/n8n-supplier-review/README.md create mode 100644 integrations/n8n-supplier-review/build.cjs create mode 100644 integrations/n8n-supplier-review/fixtures/example-register.json create mode 100644 integrations/n8n-supplier-review/fixtures/suppliers.json create mode 100644 integrations/n8n-supplier-review/functions/register.cjs create mode 100644 integrations/n8n-supplier-review/package.json create mode 100644 integrations/n8n-supplier-review/supplier-review.offline.json create mode 100644 integrations/n8n-supplier-review/supplier-review.sandbox.json create mode 100644 integrations/n8n-supplier-review/tests/native-runtime.cjs create mode 100644 integrations/n8n-supplier-review/tests/register.test.cjs create mode 100644 integrations/n8n/README.md create mode 100644 integrations/n8n/build.cjs create mode 100644 integrations/n8n/functions/review.cjs create mode 100644 integrations/n8n/onboarding-review.offline.json create mode 100644 integrations/n8n/onboarding-review.sandbox.json create mode 100644 integrations/n8n/package.json create mode 100644 integrations/n8n/tests/fixtures/base-evidence.json create mode 100644 integrations/n8n/tests/fixtures/cases.json create mode 100644 integrations/n8n/tests/fixtures/responses.cjs create mode 100644 integrations/n8n/tests/native-runtime.cjs create mode 100644 integrations/n8n/tests/review.test.cjs diff --git a/.github/workflows/n8n-supplier-review.yml b/.github/workflows/n8n-supplier-review.yml new file mode 100644 index 0000000..8e02b73 --- /dev/null +++ b/.github/workflows/n8n-supplier-review.yml @@ -0,0 +1,31 @@ +name: n8n supplier review example + +on: + push: + paths: + - "integrations/n8n-supplier-review/**" + - ".github/workflows/n8n-supplier-review.yml" + pull_request: + paths: + - "integrations/n8n-supplier-review/**" + - ".github/workflows/n8n-supplier-review.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + synthetic-fixtures: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: "24" + - name: Test row identity, duplicate handling, and failure behavior + run: npm --prefix integrations/n8n-supplier-review test + - name: Verify generated workflows and example output + run: npm --prefix integrations/n8n-supplier-review run check diff --git a/.github/workflows/n8n.yml b/.github/workflows/n8n.yml new file mode 100644 index 0000000..212b000 --- /dev/null +++ b/.github/workflows/n8n.yml @@ -0,0 +1,31 @@ +name: n8n onboarding example + +on: + push: + paths: + - "integrations/n8n/**" + - ".github/workflows/n8n.yml" + pull_request: + paths: + - "integrations/n8n/**" + - ".github/workflows/n8n.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + synthetic-routing-tests: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" + - name: Check generated workflows + run: | + npm --prefix integrations/n8n run build + git diff --exit-code -- integrations/n8n/onboarding-review.offline.json integrations/n8n/onboarding-review.sandbox.json + - name: Test synthetic routing without credentials or network requests + run: npm --prefix integrations/n8n test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49422c1..91f0f0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,3 +24,18 @@ Choose one or more of `curl`, `javascript`, `typescript`, `python`, `go`, `php`, The checks start a local HTTP server and run the actual scripts against it. They check authentication, request bodies, response output, failed requests, and reuse of the request key. No live API credentials are used. GitHub Actions runs the same checks for each language. Keep pull requests focused. Describe what changed and which checks you ran. Do not include API keys, real subject data, or private screening responses in issues, fixtures, or logs. + +## n8n workflow examples + +The [onboarding review](integrations/n8n/) and [supplier review register](integrations/n8n-supplier-review/) examples have separate fixture suites and CI path filters. Run the checks for the example you changed: + +```sh +npm --prefix integrations/n8n test +npm --prefix integrations/n8n run build +git diff --exit-code -- integrations/n8n/onboarding-review.offline.json integrations/n8n/onboarding-review.sandbox.json + +npm --prefix integrations/n8n-supplier-review test +npm --prefix integrations/n8n-supplier-review run check +``` + +After changing source functions or fixtures, regenerate that example's workflow files with its `npm run build` command and review the generated diff. Each README documents optional published-node contract tests and native n8n import/export checks. Only offline fixture workflows are executed by those native checks; do not use live credentials or real subjects for repository validation. diff --git a/README.md b/README.md index 5d6641f..f4a3cac 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Each directory has setup instructions and one standalone screening example. No S ## Workflow integrations +[n8n onboarding review](integrations/n8n/) routes a fictional onboarding event through screening, retained evidence, and four explicit review or incomplete-request outcomes. Import the credential-free offline demonstration to inspect ten synthetic cases, or use the separate sandbox workflow with the SanctionsKit node. Both preserve stable event identity and leave the onboarding decision open. + +Run its local routing tests with `npm --prefix integrations/n8n test`. The README also describes the optional published-node contract and native n8n runtime checks. + +[n8n supplier review register](integrations/n8n-supplier-review/) keeps every invented supplier row visible, including duplicates, conflicts, errors, and unavailable coverage. Import the credential-free offline demo or configure the separate synthetic sandbox adaptation. It produces execution output without approving suppliers or claiming an external saved register. + +Run its local checks with `npm --prefix integrations/n8n-supplier-review test` and `npm --prefix integrations/n8n-supplier-review run check`. + [Windmill](integrations/windmill/) takes a fictional onboarding contact through a synthetic screening and retrieves the evidence for that same result. It includes a credential resource, importable script, example output, and local tests. Both screening outcomes leave the onboarding decision open for review. Run its local HTTP tests with Node.js 24 or later: diff --git a/integrations/n8n-supplier-review/README.md b/integrations/n8n-supplier-review/README.md new file mode 100644 index 0000000..52d02ec --- /dev/null +++ b/integrations/n8n-supplier-review/README.md @@ -0,0 +1,107 @@ +# Supplier rows to an n8n review register + +Keep every row of a supplier list visible when sanctions screening is incomplete, duplicated, or needs review. This example produces one JSON register with row-level outcomes and the successful screening results. It does not approve suppliers or save a spreadsheet, database row, or external review case. + +Start with [supplier-review.offline.json](supplier-review.offline.json). It uses only n8n core nodes, invented suppliers, and local fixture responses. No account, community node, credentials, or network request is needed for that workflow. + +The separate [supplier-review.sandbox.json](supplier-review.sandbox.json) is an API adaptation for `n8n-nodes-sanctionskit` **0.1.0**. When configured and executed, it sends the invented supplier rows to SanctionsKit's free synthetic sandbox. Its actual API outcomes need not match the offline fixture. Paid production screening is a separate configuration and is not implemented in these exports. + +[SanctionsKit n8n guide](https://www.sanctionskit.com/integrations/n8n) · [API documentation](https://www.sanctionskit.com/docs) · [Sandbox](https://www.sanctionskit.com/docs/onboarding) · [Production plans](https://www.sanctionskit.com/pricing) + +## Run the offline workflow + +1. Import `supplier-review.offline.json` into n8n. +2. Open **Prepare supplier list** to inspect the nine invented input rows and `maxAttempts: 6`. +3. Execute the workflow manually. +4. Inspect the single item from **Supplier review register**. Its `rows` array contains every physical input row in its original order. + +The graph is: manual trigger → prepare list → next row → attempt or finish. Each fixture response is recorded before the next row is selected. After the final attempt or a stopping fault, the register is emitted once. + +The default fixture makes four simulated attempts: + +| Row | Outcome | Meaning | +| --- | --- | --- | +| 1 | `review` | A synthetic candidate needs review. | +| 2 | `no_match` | No candidates in the selected synthetic coverage. No clearance is granted. | +| 3 | `duplicate` | Identical request to row 1; no second attempt or independent result. | +| 4–5 | `error` | Same stable key with different supplier names; both rows rejected before any attempt. | +| 6 | `error` | Invalid supplier name; no attempt. | +| 7 | `error` | Simulated row-specific request failure. | +| 8 | `unavailable` | Simulated coverage failure. | +| 9 | `unavailable` | Not attempted because row 8 stopped the run. | + +See the exact [input fixture](fixtures/suppliers.json) and [example register](fixtures/example-register.json). `executionMode: offline_fixture` marks invented results. These fixture outcomes are not a claim about what the hosted sandbox returns for those names. + +## Stable row keys and duplicate rules + +Each request key is `sk-supplier:::`. It is independent of row position and n8n execution ID. List and supplier IDs use 1–32 ASCII letters, digits, underscores, or hyphens; revision is a positive safe integer. Keep a stable list namespace instead of generating a new one on every delivery. + +- An identical key and canonical request body appearing twice in one input list makes one attempt. Extra rows remain visible as `duplicate`, with `duplicateOfRow` pointing to the first physical row. That status does not assert that the first row succeeded. +- A repeated key with different request bodies rejects the entire conflicting group before any request. Fix the source data rather than choosing an arbitrary winner. +- For a manual retry of the same operation, preserve the exact key and body. A changed identity or a genuinely new screening needs a new revision/key. Do not change the key just to bypass a failed request. + +The example deduplicates within one run. Repeated deliveries reuse API idempotency keys, subject to the API's [idempotency contract](https://www.sanctionskit.com/docs/idempotency); this is not a durable or indefinite exactly-once ledger. A production receiver needs its own unique constraints, authenticated input, request-body persistence, and retry policy. + +The list is JSON rather than a CSV parser or spreadsheet connector. A spreadsheet adaptation must explicitly map a stable supplier ID, revision, name, and optional known country into this input shape. A physical row number is not a durable supplier ID. Country is supporting identity information, not a coverage selector. + +## Configure the synthetic sandbox adaptation + +The workflow is compatible with the published node's Create Screening operation and full response mode. It makes sequential individual requests; it does not use a native batch operation. + +1. Follow the [n8n installation guide](https://www.sanctionskit.com/integrations/n8n) for a supported instance and install `n8n-nodes-sanctionskit` 0.1.0. Node approval and actual catalog/Cloud availability are separate; confirm availability on your instance. +2. [Create a SanctionsKit workspace](https://www.sanctionskit.com/signup), create a sandbox key with `screenings:write` and `sources:read` (the credential connection test reads the source catalog), and configure a **SanctionsKit API** n8n credential with environment `sandbox`. Store the key in n8n credentials, never in this JSON or a Code node. +3. Import `supplier-review.sandbox.json` and select that credential on **Screen one supplier in sandbox**. +4. Keep the invented input rows for the initial test. This variant does not contain `fixtureOutcome` input fields or a fixture response node. +5. Run manually and inspect **Supplier review register**. The full Create response is preserved on successful rows, including candidates, coverage, versions, and interpretation limits. + +The node sends `subject.entityType: organization`, explicit empty `identifiers`, `package: sandbox@1`, standard retention, and the stable row key as the caller reference. Create responses do not return the retained subject/reference; row identity comes from the request state and n8n item linking. A successful result is not a full retained evidence bundle. To retrieve one, additionally grant `results:read` and use [Get Evidence](https://www.sanctionskit.com/docs/evidence) for that exact screening ID and validate its linkage before adding it to a stored record. + +`maxAttempts` limits a run to 1–20 attempts; input is capped at 100 rows. There is no automatic retry. The workflow timeout is 900 seconds, above the 20 × 30-second HTTP-attempt bound plus processing overhead. A cancelled workflow, host failure, or instance-level limit can still prevent final output; the execution register is not crash-safe storage. Coverage, policy, access, allowance, rate-limit, and unknown failures stop subsequent requests. Remaining rows are marked unavailable and not attempted. Only the explicit row-specific codes `invalid_request`, `idempotency_conflict`, and `unsupported_entity_type` permit the next row to proceed. Resolve the recorded error before retrying with the same unchanged request. + +Node 0.1.0 exposes continued failures as a sanitized error string, not a structured status/code object. This example recognizes its exact message format and does not infer a retry delay or an HTTP status. An unrecognized message stays an error and stops the run. If a workspace requires a policy that is not configured here, it will fail visibly; do not weaken the workspace's controls to make the demo pass. + +## Retention and production limits + +The register exists only as n8n execution output. How long it remains available depends on the instance's execution-saving and pruning settings. No database, spreadsheet, reviewer assignment, evidence download, webhook receiver, or external write is included. Review your n8n execution-data access and retention settings before adapting the workflow to real supplier details. + +The sandbox version is deliberately guarded to synthetic coverage and `sandbox@1`. Do not switch its credential to production and assume it is ready. Production requires a paid plan, production credentials, selected available sources or a package, any required approved policy, and a reviewed adaptation of the response guards, storage, privacy, and retry behavior. Consult [source availability](https://www.sanctionskit.com/docs/sources), [errors](https://www.sanctionskit.com/docs/errors), [retention](https://www.sanctionskit.com/docs/retention), and [pricing](https://www.sanctionskit.com/pricing). + +Every row keeps `businessDecision: not_decided`. Neither a candidate score nor a no-match result is a business approval or legal clearance. + +## Local checks + +Use Node.js 24 or later. The pure fixture checks have no dependencies: + +```sh +npm --prefix integrations/n8n-supplier-review test +npm --prefix integrations/n8n-supplier-review run check +npm --prefix integrations/n8n-supplier-review run demo +``` + +Edit the pure functions, builder, or fixture and run `npm --prefix integrations/n8n-supplier-review run build` to regenerate both self-contained workflow JSON files and the example register. The builder embeds the same functions in n8n Code nodes; n8n does not need to load local modules. + +Optional published-node contract test, with an independently unpacked `n8n-nodes-sanctionskit@0.1.0` package and its runtime dependencies available: + +```sh +SANCTIONSKIT_NODE_PACKAGE=/absolute/path/to/package \ + npm --prefix integrations/n8n-supplier-review test +``` + +That test intercepts the node's HTTP helper and uses a clearly fake test credential. It makes no hosted request. + +Optional native validation with an installed n8n **2.41.4** CLI: + +```sh +N8N_BIN=/absolute/path/to/n8n/bin/n8n \ + node integrations/n8n-supplier-review/tests/native-runtime.cjs +``` + +The native check uses fresh temporary n8n state, imports/exports both variants twice, and executes only the core-node offline workflow. It asserts the actual final register, row count, outcome counts, and loop attempts. The sandbox workflow is not authenticated or executed by this check. It needs a local task-runner port; `N8N_TEST_RUNNER_PORT` selects one. Test reports and logs are written to a temporary directory printed on success, outside the repository. + +## Verification recorded 6 October 2026 + +Both exact workflow files passed native n8n 2.41.4 import/export and a second clean round trip. The offline workflow executed successfully and produced all nine expected rows after four simulated attempts. Local checks passed 27 tests when the optional published-node contract test was enabled. The two synthetic result objects also passed a structure check against the current public OpenAPI schema. No authenticated sandbox or production execution is claimed. + +## License + +The example code and invented fixtures are covered by the repository's [MIT license](../../LICENSE). Hosted API use is subject to [SanctionsKit's terms](https://www.sanctionskit.com/terms). diff --git a/integrations/n8n-supplier-review/build.cjs b/integrations/n8n-supplier-review/build.cjs new file mode 100644 index 0000000..6f7a374 --- /dev/null +++ b/integrations/n8n-supplier-review/build.cjs @@ -0,0 +1,51 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const input = require('./fixtures/suppliers.json'); +const core = fs.readFileSync(path.join(__dirname, 'functions/register.cjs'), 'utf8').replace(/^module\.exports =.*;\s*$/m, ''); +const code = (name, id, body, position) => ({parameters: {mode: 'runOnceForAllItems', jsCode: core + '\n' + body}, id, name, type: 'n8n-nodes-base.code', typeVersion: 2, position}); +function workflow(mode) { + const offline = mode === 'offline_fixture'; + const rows = offline ? input : {...input, rows: input.rows.map(({fixtureOutcome, ...row}) => row)}; + const nodes = [ + {parameters: {}, id: 'supplier-manual', name: 'Run manually', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 100]}, + code('Prepare supplier list', 'supplier-prepare', `const input = ${JSON.stringify(rows, null, 2)};\nreturn [{json: prepare(input, '${mode}')}];`, [240, 100]), + code('Next supplier row', 'supplier-next', 'return [{json: next($input.first().json), pairedItem: {item: 0}}];', [500, 100]), + {parameters: {mode: 'expression', numberOutputs: 2, output: '={{ $json.current ? 0 : 1 }}'}, id: 'supplier-route', name: 'Attempt or finish', type: 'n8n-nodes-base.switch', typeVersion: 3.4, position: [740, 100]}, + offline ? code('Synthetic response only', 'supplier-response', 'return [{json: fixtureResponse($input.first().json.current), pairedItem: {item: 0}}];', [990, 0]) : { + parameters: {resource: 'screening', operation: 'create', name: '={{ $json.current.body.subject.name }}', entityType: 'organization', coverage: 'sandbox', idempotencyKey: '={{ $json.current.rowKey }}', options: {country: '={{ $json.current.body.subject.country || "" }}', identifiers: '[]', reference: '={{ $json.current.body.reference }}', retention: 'standard'}, simplify: false}, + id: 'supplier-response', name: 'Screen one supplier in sandbox', type: 'n8n-nodes-sanctionskit.sanctionsKit', typeVersion: 1, position: [990, 0], onError: 'continueRegularOutput', retryOnFail: false, + }, + code('Record one row outcome', 'supplier-record', "const state = clone($('Next supplier row').item.json);\nreturn [{json: record(state, $input.first().json), pairedItem: {item: 0}}];", [1240, 0]), + code('Supplier review register', 'supplier-finish', 'return [{json: finish($input.first().json), pairedItem: {item: 0}}];', [990, 270]), + {parameters: {content: offline ? '## Offline supplier-row review demo\nAll suppliers and responses are invented. No credentials, community node or network request. Inspect Supplier review register after execution. This output is not a saved spreadsheet or external register.\n\nStable list/supplier/revision keys; identical duplicates skip; conflicting duplicates fail; shared faults stop further attempts. No supplier is automatically approved.' : '## Synthetic sandbox adaptation\nInstall n8n-nodes-sanctionskit 0.1.0 and select a sandbox credential on Screen one supplier in sandbox. No credentials are embedded. This version calls the hosted API when executed; only invented supplier rows are provided. Actual outcomes may differ from offline fixtures.\n\nOne attempt at a time; no automatic retry. Shared/unknown failures stop remaining requests. Review the README before adapting. Production screening is paid and is not configured here.', height: 300, width: 800}, id: 'supplier-notes', name: 'Read first', type: 'n8n-nodes-base.stickyNote', typeVersion: 1, position: [200, -360]}, + ]; + const requestName = offline ? 'Synthetic response only' : 'Screen one supplier in sandbox'; + const connect = name => [{node: name, type: 'main', index: 0}]; + return { + id: offline ? 'skSupplierReviewOfflineV1' : 'skSupplierReviewSandboxV1', + name: offline ? 'SanctionsKit supplier row register - offline fixtures' : 'SanctionsKit supplier row register - synthetic sandbox', + nodes, + connections: { + 'Run manually': {main: [connect('Prepare supplier list')]}, + 'Prepare supplier list': {main: [connect('Next supplier row')]}, + 'Next supplier row': {main: [connect('Attempt or finish')]}, + 'Attempt or finish': {main: [connect(requestName), connect('Supplier review register')]}, + [requestName]: {main: [connect('Record one row outcome')]}, + 'Record one row outcome': {main: [connect('Next supplier row')]}, + }, + active: false, settings: {executionOrder: 'v1', executionTimeout: 900}, pinData: {}, tags: [], + }; +} +for (const [name, mode] of [['supplier-review.offline.json', 'offline_fixture'], ['supplier-review.sandbox.json', 'sandbox_api']]) { + const output = JSON.stringify(workflow(mode), null, 2) + '\n'; + const file = path.join(__dirname, name); + if (process.argv.includes('--check')) { + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== output) throw new Error(`${name} is stale; run npm run build`); + } else fs.writeFileSync(file, output); +} +const example = JSON.stringify(require('./functions/register.cjs').runOffline(input), null, 2) + '\n'; +const exampleFile = path.join(__dirname, 'fixtures/example-register.json'); +if (process.argv.includes('--check')) { + if (fs.readFileSync(exampleFile, 'utf8') !== example) throw new Error('example-register.json is stale'); +} else fs.writeFileSync(exampleFile, example); diff --git a/integrations/n8n-supplier-review/fixtures/example-register.json b/integrations/n8n-supplier-review/fixtures/example-register.json new file mode 100644 index 0000000..4e175f4 --- /dev/null +++ b/integrations/n8n-supplier-review/fixtures/example-register.json @@ -0,0 +1,242 @@ +{ + "format": "sanctionskit-supplier-review-demo@1", + "executionMode": "offline_fixture", + "listId": "demo-suppliers-v1", + "rowCount": 9, + "attempts": 4, + "maxAttempts": 6, + "stopReason": "coverage_unavailable", + "counts": { + "review": 1, + "no_match": 1, + "duplicate": 1, + "error": 4, + "unavailable": 2 + }, + "storage": "n8n_execution_output_only", + "evidenceBundleRetrieved": false, + "limitations": "No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.", + "rows": [ + { + "rowNumber": 1, + "supplierId": "supplier-001", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-001:1", + "supplierName": "Example Meridian Parts Ltd", + "rowStatus": "review", + "reason": "candidates_need_review", + "attempted": true, + "businessDecision": "not_decided", + "screeningId": "00000000-0000-4000-8000-000000000001", + "screeningStatus": "potential_match", + "candidateCount": 1, + "duplicateOfRow": null, + "result": { + "id": "00000000-0000-4000-8000-000000000001", + "environment": "sandbox", + "status": "potential_match", + "createdAt": "2026-10-06T12:00:00.000Z", + "matches": [ + { + "record": { + "id": "synthetic-supplier-001", + "sourceId": "sandbox-synthetic", + "entityType": "organization", + "authority": "SanctionsKit synthetic fixture", + "list": "Local demonstration only", + "names": [ + { + "value": "Example Meridian Parts Ltd", + "kind": "primary" + } + ], + "identifiers": [], + "birthDates": [], + "addresses": [], + "nationalities": [], + "designations": [], + "extensions": {}, + "sourceUrl": "https://www.sanctionskit.com/docs/onboarding" + }, + "score": 100, + "evidence": [ + { + "field": "name", + "queryValue": "Example Meridian Parts Ltd", + "sourceValue": "Example Meridian Parts Ltd", + "method": "fixture", + "contribution": 100, + "explanation": "Invented local fixture; not an API screening or real designation." + } + ], + "conflicts": [] + } + ], + "coverage": [ + { + "sourceId": "sandbox-synthetic", + "version": "1", + "retrievedAt": "2026-10-06T12:00:00.000Z", + "fresh": true + } + ], + "versions": { + "dataset": "sandbox-synthetic-v1", + "matchingEngine": "deterministic-1.1.1", + "policy": "review-1.1.0", + "package": "sandbox@1" + }, + "disclaimer": "Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing." + } + }, + { + "rowNumber": 2, + "supplierId": "supplier-002", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-002:1", + "supplierName": "Example Juniper Packing Ltd", + "rowStatus": "no_match", + "reason": "no_candidates_in_selected_coverage", + "attempted": true, + "businessDecision": "not_decided", + "screeningId": "00000000-0000-4000-8000-000000000002", + "screeningStatus": "no_match", + "candidateCount": 0, + "duplicateOfRow": null, + "result": { + "id": "00000000-0000-4000-8000-000000000002", + "environment": "sandbox", + "status": "no_match", + "createdAt": "2026-10-06T12:00:00.000Z", + "matches": [], + "coverage": [ + { + "sourceId": "sandbox-synthetic", + "version": "1", + "retrievedAt": "2026-10-06T12:00:00.000Z", + "fresh": true + } + ], + "versions": { + "dataset": "sandbox-synthetic-v1", + "matchingEngine": "deterministic-1.1.1", + "policy": "review-1.1.0", + "package": "sandbox@1" + }, + "disclaimer": "Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing." + } + }, + { + "rowNumber": 3, + "supplierId": "supplier-001", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-001:1", + "supplierName": "Example Meridian Parts Ltd", + "rowStatus": "duplicate", + "reason": "identical_request_in_this_run", + "attempted": false, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": 1, + "result": null + }, + { + "rowNumber": 4, + "supplierId": "supplier-003", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-003:1", + "supplierName": "Example Beacon Shipping Ltd", + "rowStatus": "error", + "reason": "duplicate_key_conflict", + "attempted": false, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + }, + { + "rowNumber": 5, + "supplierId": "supplier-003", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-003:1", + "supplierName": "Example Beacon Shipping Holdings Ltd", + "rowStatus": "error", + "reason": "duplicate_key_conflict", + "attempted": false, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + }, + { + "rowNumber": 6, + "supplierId": "supplier-004", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-004:1", + "supplierName": "X", + "rowStatus": "error", + "reason": "invalid_supplier_row", + "attempted": false, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + }, + { + "rowNumber": 7, + "supplierId": "supplier-005", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-005:1", + "supplierName": "Example Orchard Metals Ltd", + "rowStatus": "error", + "reason": "invalid_request", + "attempted": true, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + }, + { + "rowNumber": 8, + "supplierId": "supplier-006", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-006:1", + "supplierName": "Example Cedar Supplies Ltd", + "rowStatus": "unavailable", + "reason": "coverage_unavailable", + "attempted": true, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + }, + { + "rowNumber": 9, + "supplierId": "supplier-007", + "revision": 1, + "rowKey": "sk-supplier:demo-suppliers-v1:supplier-007:1", + "supplierName": "Example Harbor Components Ltd", + "rowStatus": "unavailable", + "reason": "run_stopped:coverage_unavailable", + "attempted": false, + "businessDecision": "not_decided", + "screeningId": null, + "screeningStatus": null, + "candidateCount": null, + "duplicateOfRow": null, + "result": null + } + ] +} diff --git a/integrations/n8n-supplier-review/fixtures/suppliers.json b/integrations/n8n-supplier-review/fixtures/suppliers.json new file mode 100644 index 0000000..2f4d048 --- /dev/null +++ b/integrations/n8n-supplier-review/fixtures/suppliers.json @@ -0,0 +1,15 @@ +{ + "listId": "demo-suppliers-v1", + "maxAttempts": 6, + "rows": [ + {"supplierId": "supplier-001", "revision": 1, "name": "Example Meridian Parts Ltd", "country": "GB", "fixtureOutcome": "potential_match"}, + {"supplierId": "supplier-002", "revision": 1, "name": "Example Juniper Packing Ltd", "country": "GB", "fixtureOutcome": "no_match"}, + {"supplierId": "supplier-001", "revision": 1, "name": "Example Meridian Parts Ltd", "country": "GB", "fixtureOutcome": "potential_match"}, + {"supplierId": "supplier-003", "revision": 1, "name": "Example Beacon Shipping Ltd", "fixtureOutcome": "no_match"}, + {"supplierId": "supplier-003", "revision": 1, "name": "Example Beacon Shipping Holdings Ltd", "fixtureOutcome": "no_match"}, + {"supplierId": "supplier-004", "revision": 1, "name": "X", "fixtureOutcome": "no_match"}, + {"supplierId": "supplier-005", "revision": 1, "name": "Example Orchard Metals Ltd", "fixtureOutcome": "invalid_request"}, + {"supplierId": "supplier-006", "revision": 1, "name": "Example Cedar Supplies Ltd", "fixtureOutcome": "coverage_unavailable"}, + {"supplierId": "supplier-007", "revision": 1, "name": "Example Harbor Components Ltd", "fixtureOutcome": "no_match"} + ] +} diff --git a/integrations/n8n-supplier-review/functions/register.cjs b/integrations/n8n-supplier-review/functions/register.cjs new file mode 100644 index 0000000..5fd760d --- /dev/null +++ b/integrations/n8n-supplier-review/functions/register.cjs @@ -0,0 +1,196 @@ +'use strict'; + +// Pure functions embedded into the importable Code nodes by build.cjs. +const KEY_PART = /^[A-Za-z0-9_-]{1,32}$/; +const UNAVAILABLE = new Set([ + 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable', + 'usage_cap_reached', 'rate_limited', 'subscription_required', + 'production_screening_disabled', 'screening_policy_required', + 'screening_policy_unavailable', 'screening_policy_changed', + 'invalid_api_key', 'insufficient_scope', 'authentication_required', +]); +const ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']); + +function clone(value) { return JSON.parse(JSON.stringify(value)); } +function baseRow(row, index) { + return { + rowNumber: index + 1, + supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null, + revision: Number.isSafeInteger(row?.revision) ? row.revision : null, + rowKey: null, + supplierName: typeof row?.name === 'string' ? row.name : null, + rowStatus: 'unavailable', reason: 'not_attempted', attempted: false, + businessDecision: 'not_decided', screeningId: null, + screeningStatus: null, candidateCount: null, + duplicateOfRow: null, result: null, + }; +} +function rowIdentity(listId, row) { + if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) || + !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row'); + return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`; +} +function requestFor(listId, row) { + const rowKey = rowIdentity(listId, row); + if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 || + (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) { + throw new Error('invalid_supplier_row'); + } + return { + rowKey, + body: { + subject: { name: row.name.trim(), entityType: 'organization', identifiers: [], + ...(row.country === undefined ? {} : {country: row.country.trim()}) }, + package: 'sandbox@1', reference: rowKey, retention: 'standard', + }, + }; +} +function prepare(input, mode) { + if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 || + !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 || + !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration'); + const state = { + mode, listId: input.listId, maxAttempts: input.maxAttempts, + attempts: 0, stopReason: null, queue: [], current: null, + register: input.rows.map(baseRow), + }; + const groups = new Map(); + input.rows.forEach((row, index) => { + let rowKey; + try { rowKey = rowIdentity(input.listId, row); } + catch { + Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'}); + return; + } + state.register[index].rowKey = rowKey; + const group = groups.get(rowKey) ?? []; + try { + const planned = {index, ...requestFor(input.listId, row)}; + if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome; + group.push(planned); + } catch { group.push({index, rowKey, invalid: true}); } + groups.set(rowKey, group); + }); + for (const group of groups.values()) { + if (group.some(row => row.invalid)) { + for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'}); + continue; + } + if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) { + for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'}); + continue; + } + state.queue.push(group[0]); + for (const row of group.slice(1)) Object.assign(state.register[row.index], { + rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1, + }); + } + state.queue.sort((a, b) => a.index - b.index); + return state; +} +function next(state) { + if (state.current) throw new Error('previous_attempt_not_recorded'); + if (state.stopReason || state.attempts >= state.maxAttempts) { + const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit'; + for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason}); + state.queue = []; + } + state.current = state.queue.shift() ?? null; + if (state.current) state.attempts += 1; + return state; +} +function validMatch(match) { + const r = match?.record; + return match && typeof match === 'object' && !Array.isArray(match) && + Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 && + Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') && + Array.isArray(match.evidence) && match.evidence.every(e => e && + ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) && + r && typeof r === 'object' && !Array.isArray(r) && + ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) && + r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) && + Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) && + ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) && + r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions); +} +function validResult(result) { + return result && typeof result === 'object' && !Array.isArray(result) && + /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') && + result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) && + typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) && + Array.isArray(result.matches) && result.matches.every(validMatch) && + (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) && + Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c => + c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 && + c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) && + result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) && + result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0; +} +function record(state, response) { + if (!state.current) throw new Error('no_current_attempt'); + const row = state.register[state.current.index]; + row.attempted = true; + // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code. + if (response && typeof response.error === 'string') { + const code = /^SanctionsKit request failed \(([a-z_]{1,80})\)$/.exec(response.error)?.[1] ?? 'request_failed'; + row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error'; + row.reason = code; + // Fail closed for shared access/coverage/allowance faults and unknown failures. + if (!ROW_ERROR.has(code)) state.stopReason = code; + } else if (!validResult(response)) { + row.rowStatus = 'error'; + row.reason = 'invalid_or_unexpected_result'; + state.stopReason = row.reason; + } else { + row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match'; + row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage'; + row.screeningId = response.id; + row.screeningStatus = response.status; + row.candidateCount = response.matches.length; + row.result = clone(response); + } + state.current = null; + return state; +} +function finish(state) { + if (state.current || state.queue.length) throw new Error('unfinished_register'); + return { + format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode, + listId: state.listId, rowCount: state.register.length, + attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason, + counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}), + storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false, + limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.', + rows: state.register, + }; +} +function fixtureResponse(current) { + const outcome = current.fixtureOutcome; + if (outcome !== 'potential_match' && outcome !== 'no_match') { + return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`}; + } + return { + id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`, + environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z', + matches: outcome === 'no_match' ? [] : [{ + record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization', + authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only', + names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [], + birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {}, + sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'}, + score: 100, + evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name, + method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}], + conflicts: [], + }], + coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}], + versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'}, + disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.', + }; +} +function runOffline(input) { + let state = prepare(input, 'offline_fixture'); + while (next(state).current) state = record(state, fixtureResponse(state.current)); + return finish(state); +} +module.exports = {prepare, next, record, finish, fixtureResponse, runOffline, requestFor, validResult}; diff --git a/integrations/n8n-supplier-review/package.json b/integrations/n8n-supplier-review/package.json new file mode 100644 index 0000000..acd7617 --- /dev/null +++ b/integrations/n8n-supplier-review/package.json @@ -0,0 +1,13 @@ +{ + "name": "sanctionskit-n8n-supplier-review-example", + "version": "1.0.0", + "private": true, + "description": "Credential-free supplier-row review register fixtures and n8n sandbox adaptation", + "engines": {"node": ">=24"}, + "scripts": { + "build": "node build.cjs", + "check": "node build.cjs --check", + "test": "node --test tests/*.test.cjs", + "demo": "node -e \"console.log(JSON.stringify(require('./functions/register.cjs').runOffline(require('./fixtures/suppliers.json')), null, 2))\"" + } +} diff --git a/integrations/n8n-supplier-review/supplier-review.offline.json b/integrations/n8n-supplier-review/supplier-review.offline.json new file mode 100644 index 0000000..94bac35 --- /dev/null +++ b/integrations/n8n-supplier-review/supplier-review.offline.json @@ -0,0 +1,199 @@ +{ + "id": "skSupplierReviewOfflineV1", + "name": "SanctionsKit supplier row register - offline fixtures", + "nodes": [ + { + "parameters": {}, + "id": "supplier-manual", + "name": "Run manually", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nconst input = {\n \"listId\": \"demo-suppliers-v1\",\n \"maxAttempts\": 6,\n \"rows\": [\n {\n \"supplierId\": \"supplier-001\",\n \"revision\": 1,\n \"name\": \"Example Meridian Parts Ltd\",\n \"country\": \"GB\",\n \"fixtureOutcome\": \"potential_match\"\n },\n {\n \"supplierId\": \"supplier-002\",\n \"revision\": 1,\n \"name\": \"Example Juniper Packing Ltd\",\n \"country\": \"GB\",\n \"fixtureOutcome\": \"no_match\"\n },\n {\n \"supplierId\": \"supplier-001\",\n \"revision\": 1,\n \"name\": \"Example Meridian Parts Ltd\",\n \"country\": \"GB\",\n \"fixtureOutcome\": \"potential_match\"\n },\n {\n \"supplierId\": \"supplier-003\",\n \"revision\": 1,\n \"name\": \"Example Beacon Shipping Ltd\",\n \"fixtureOutcome\": \"no_match\"\n },\n {\n \"supplierId\": \"supplier-003\",\n \"revision\": 1,\n \"name\": \"Example Beacon Shipping Holdings Ltd\",\n \"fixtureOutcome\": \"no_match\"\n },\n {\n \"supplierId\": \"supplier-004\",\n \"revision\": 1,\n \"name\": \"X\",\n \"fixtureOutcome\": \"no_match\"\n },\n {\n \"supplierId\": \"supplier-005\",\n \"revision\": 1,\n \"name\": \"Example Orchard Metals Ltd\",\n \"fixtureOutcome\": \"invalid_request\"\n },\n {\n \"supplierId\": \"supplier-006\",\n \"revision\": 1,\n \"name\": \"Example Cedar Supplies Ltd\",\n \"fixtureOutcome\": \"coverage_unavailable\"\n },\n {\n \"supplierId\": \"supplier-007\",\n \"revision\": 1,\n \"name\": \"Example Harbor Components Ltd\",\n \"fixtureOutcome\": \"no_match\"\n }\n ]\n};\nreturn [{json: prepare(input, 'offline_fixture')}];" + }, + "id": "supplier-prepare", + "name": "Prepare supplier list", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 240, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nreturn [{json: next($input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-next", + "name": "Next supplier row", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 500, + 100 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 2, + "output": "={{ $json.current ? 0 : 1 }}" + }, + "id": "supplier-route", + "name": "Attempt or finish", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 740, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nreturn [{json: fixtureResponse($input.first().json.current), pairedItem: {item: 0}}];" + }, + "id": "supplier-response", + "name": "Synthetic response only", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 990, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nconst state = clone($('Next supplier row').item.json);\nreturn [{json: record(state, $input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-record", + "name": "Record one row outcome", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1240, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nreturn [{json: finish($input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-finish", + "name": "Supplier review register", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 990, + 270 + ] + }, + { + "parameters": { + "content": "## Offline supplier-row review demo\nAll suppliers and responses are invented. No credentials, community node or network request. Inspect Supplier review register after execution. This output is not a saved spreadsheet or external register.\n\nStable list/supplier/revision keys; identical duplicates skip; conflicting duplicates fail; shared faults stop further attempts. No supplier is automatically approved.", + "height": 300, + "width": 800 + }, + "id": "supplier-notes", + "name": "Read first", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + -360 + ] + } + ], + "connections": { + "Run manually": { + "main": [ + [ + { + "node": "Prepare supplier list", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare supplier list": { + "main": [ + [ + { + "node": "Next supplier row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Next supplier row": { + "main": [ + [ + { + "node": "Attempt or finish", + "type": "main", + "index": 0 + } + ] + ] + }, + "Attempt or finish": { + "main": [ + [ + { + "node": "Synthetic response only", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Supplier review register", + "type": "main", + "index": 0 + } + ] + ] + }, + "Synthetic response only": { + "main": [ + [ + { + "node": "Record one row outcome", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record one row outcome": { + "main": [ + [ + { + "node": "Next supplier row", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "executionTimeout": 900 + }, + "pinData": {}, + "tags": [] +} diff --git a/integrations/n8n-supplier-review/supplier-review.sandbox.json b/integrations/n8n-supplier-review/supplier-review.sandbox.json new file mode 100644 index 0000000..a60acaa --- /dev/null +++ b/integrations/n8n-supplier-review/supplier-review.sandbox.json @@ -0,0 +1,212 @@ +{ + "id": "skSupplierReviewSandboxV1", + "name": "SanctionsKit supplier row register - synthetic sandbox", + "nodes": [ + { + "parameters": {}, + "id": "supplier-manual", + "name": "Run manually", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nconst input = {\n \"listId\": \"demo-suppliers-v1\",\n \"maxAttempts\": 6,\n \"rows\": [\n {\n \"supplierId\": \"supplier-001\",\n \"revision\": 1,\n \"name\": \"Example Meridian Parts Ltd\",\n \"country\": \"GB\"\n },\n {\n \"supplierId\": \"supplier-002\",\n \"revision\": 1,\n \"name\": \"Example Juniper Packing Ltd\",\n \"country\": \"GB\"\n },\n {\n \"supplierId\": \"supplier-001\",\n \"revision\": 1,\n \"name\": \"Example Meridian Parts Ltd\",\n \"country\": \"GB\"\n },\n {\n \"supplierId\": \"supplier-003\",\n \"revision\": 1,\n \"name\": \"Example Beacon Shipping Ltd\"\n },\n {\n \"supplierId\": \"supplier-003\",\n \"revision\": 1,\n \"name\": \"Example Beacon Shipping Holdings Ltd\"\n },\n {\n \"supplierId\": \"supplier-004\",\n \"revision\": 1,\n \"name\": \"X\"\n },\n {\n \"supplierId\": \"supplier-005\",\n \"revision\": 1,\n \"name\": \"Example Orchard Metals Ltd\"\n },\n {\n \"supplierId\": \"supplier-006\",\n \"revision\": 1,\n \"name\": \"Example Cedar Supplies Ltd\"\n },\n {\n \"supplierId\": \"supplier-007\",\n \"revision\": 1,\n \"name\": \"Example Harbor Components Ltd\"\n }\n ]\n};\nreturn [{json: prepare(input, 'sandbox_api')}];" + }, + "id": "supplier-prepare", + "name": "Prepare supplier list", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 240, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nreturn [{json: next($input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-next", + "name": "Next supplier row", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 500, + 100 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 2, + "output": "={{ $json.current ? 0 : 1 }}" + }, + "id": "supplier-route", + "name": "Attempt or finish", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 740, + 100 + ] + }, + { + "parameters": { + "resource": "screening", + "operation": "create", + "name": "={{ $json.current.body.subject.name }}", + "entityType": "organization", + "coverage": "sandbox", + "idempotencyKey": "={{ $json.current.rowKey }}", + "options": { + "country": "={{ $json.current.body.subject.country || \"\" }}", + "identifiers": "[]", + "reference": "={{ $json.current.body.reference }}", + "retention": "standard" + }, + "simplify": false + }, + "id": "supplier-response", + "name": "Screen one supplier in sandbox", + "type": "n8n-nodes-sanctionskit.sanctionsKit", + "typeVersion": 1, + "position": [ + 990, + 0 + ], + "onError": "continueRegularOutput", + "retryOnFail": false + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nconst state = clone($('Next supplier row').item.json);\nreturn [{json: record(state, $input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-record", + "name": "Record one row outcome", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1240, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "'use strict';\n\n// Pure functions embedded into the importable Code nodes by build.cjs.\nconst KEY_PART = /^[A-Za-z0-9_-]{1,32}$/;\nconst UNAVAILABLE = new Set([\n 'coverage_unavailable', 'stale_coverage', 'temporarily_unavailable',\n 'usage_cap_reached', 'rate_limited', 'subscription_required',\n 'production_screening_disabled', 'screening_policy_required',\n 'screening_policy_unavailable', 'screening_policy_changed',\n 'invalid_api_key', 'insufficient_scope', 'authentication_required',\n]);\nconst ROW_ERROR = new Set(['invalid_request', 'idempotency_conflict', 'unsupported_entity_type']);\n\nfunction clone(value) { return JSON.parse(JSON.stringify(value)); }\nfunction baseRow(row, index) {\n return {\n rowNumber: index + 1,\n supplierId: typeof row?.supplierId === 'string' ? row.supplierId : null,\n revision: Number.isSafeInteger(row?.revision) ? row.revision : null,\n rowKey: null,\n supplierName: typeof row?.name === 'string' ? row.name : null,\n rowStatus: 'unavailable', reason: 'not_attempted', attempted: false,\n businessDecision: 'not_decided', screeningId: null,\n screeningStatus: null, candidateCount: null,\n duplicateOfRow: null, result: null,\n };\n}\nfunction rowIdentity(listId, row) {\n if (!row || typeof row.supplierId !== 'string' || !KEY_PART.test(row.supplierId) ||\n !Number.isSafeInteger(row.revision) || row.revision < 1) throw new Error('invalid_supplier_row');\n return `sk-supplier:${listId}:${row.supplierId}:${row.revision}`;\n}\nfunction requestFor(listId, row) {\n const rowKey = rowIdentity(listId, row);\n if (typeof row.name !== 'string' || row.name.trim().length < 2 || row.name.trim().length > 300 ||\n (row.country !== undefined && (typeof row.country !== 'string' || row.country.trim().length < 2 || row.country.trim().length > 100))) {\n throw new Error('invalid_supplier_row');\n }\n return {\n rowKey,\n body: {\n subject: { name: row.name.trim(), entityType: 'organization', identifiers: [],\n ...(row.country === undefined ? {} : {country: row.country.trim()}) },\n package: 'sandbox@1', reference: rowKey, retention: 'standard',\n },\n };\n}\nfunction prepare(input, mode) {\n if (!input || typeof input.listId !== 'string' || !KEY_PART.test(input.listId) || !Array.isArray(input.rows) || input.rows.length > 100 ||\n !Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 20 ||\n !['offline_fixture', 'sandbox_api'].includes(mode)) throw new Error('invalid_list_configuration');\n const state = {\n mode, listId: input.listId, maxAttempts: input.maxAttempts,\n attempts: 0, stopReason: null, queue: [], current: null,\n register: input.rows.map(baseRow),\n };\n const groups = new Map();\n input.rows.forEach((row, index) => {\n let rowKey;\n try { rowKey = rowIdentity(input.listId, row); }\n catch {\n Object.assign(state.register[index], {rowStatus: 'error', reason: 'invalid_supplier_row'});\n return;\n }\n state.register[index].rowKey = rowKey;\n const group = groups.get(rowKey) ?? [];\n try {\n const planned = {index, ...requestFor(input.listId, row)};\n if (mode === 'offline_fixture') planned.fixtureOutcome = row.fixtureOutcome;\n group.push(planned);\n } catch { group.push({index, rowKey, invalid: true}); }\n groups.set(rowKey, group);\n });\n for (const group of groups.values()) {\n if (group.some(row => row.invalid)) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: group.length > 1 ? 'duplicate_key_conflict' : 'invalid_supplier_row'});\n continue;\n }\n if (new Set(group.map(row => JSON.stringify(row.body))).size !== 1) {\n for (const row of group) Object.assign(state.register[row.index], {rowStatus: 'error', reason: 'duplicate_key_conflict'});\n continue;\n }\n state.queue.push(group[0]);\n for (const row of group.slice(1)) Object.assign(state.register[row.index], {\n rowStatus: 'duplicate', reason: 'identical_request_in_this_run', duplicateOfRow: group[0].index + 1,\n });\n }\n state.queue.sort((a, b) => a.index - b.index);\n return state;\n}\nfunction next(state) {\n if (state.current) throw new Error('previous_attempt_not_recorded');\n if (state.stopReason || state.attempts >= state.maxAttempts) {\n const reason = state.stopReason ? `run_stopped:${state.stopReason}` : 'run_attempt_limit';\n for (const row of state.queue) Object.assign(state.register[row.index], {rowStatus: 'unavailable', reason});\n state.queue = [];\n }\n state.current = state.queue.shift() ?? null;\n if (state.current) state.attempts += 1;\n return state;\n}\nfunction validMatch(match) {\n const r = match?.record;\n return match && typeof match === 'object' && !Array.isArray(match) &&\n Number.isFinite(match.score) && match.score >= 0 && match.score <= 100 &&\n Array.isArray(match.conflicts) && match.conflicts.every(c => typeof c === 'string') &&\n Array.isArray(match.evidence) && match.evidence.every(e => e &&\n ['field','queryValue','sourceValue','method','explanation'].every(k => typeof e[k] === 'string') && Number.isFinite(e.contribution)) &&\n r && typeof r === 'object' && !Array.isArray(r) &&\n ['id','sourceId','authority','list','sourceUrl'].every(k => typeof r[k] === 'string' && r[k].length > 0) &&\n r.sourceId === 'sandbox-synthetic' && ['person','organization','vessel','aircraft','other'].includes(r.entityType) &&\n Array.isArray(r.names) && r.names.length > 0 && r.names.every(n => n && typeof n.value === 'string' && n.value.length > 0 && ['primary','alias'].includes(n.kind)) &&\n ['identifiers','birthDates','addresses','nationalities','designations'].every(k => Array.isArray(r[k])) &&\n r.extensions && typeof r.extensions === 'object' && !Array.isArray(r.extensions);\n}\nfunction validResult(result) {\n return result && typeof result === 'object' && !Array.isArray(result) &&\n /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(result.id ?? '') &&\n result.environment === 'sandbox' && ['potential_match', 'no_match'].includes(result.status) &&\n typeof result.createdAt === 'string' && Number.isFinite(Date.parse(result.createdAt)) &&\n Array.isArray(result.matches) && result.matches.every(validMatch) &&\n (result.status === 'potential_match' ? result.matches.length > 0 : result.matches.length === 0) &&\n Array.isArray(result.coverage) && result.coverage.length > 0 && result.coverage.every(c =>\n c && c.sourceId === 'sandbox-synthetic' && typeof c.version === 'string' && c.version.length > 0 &&\n c.fresh === true && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt))) &&\n result.versions && ['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k].length > 0) &&\n result.versions.package === 'sandbox@1' && typeof result.disclaimer === 'string' && result.disclaimer.length > 0;\n}\nfunction record(state, response) {\n if (!state.current) throw new Error('no_current_attempt');\n const row = state.register[state.current.index];\n row.attempted = true;\n // Node 0.1.0 emits a sanitized message, not an HTTP status or structured code.\n if (response && typeof response.error === 'string') {\n const code = /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error)?.[1] ?? 'request_failed';\n row.rowStatus = UNAVAILABLE.has(code) ? 'unavailable' : 'error';\n row.reason = code;\n // Fail closed for shared access/coverage/allowance faults and unknown failures.\n if (!ROW_ERROR.has(code)) state.stopReason = code;\n } else if (!validResult(response)) {\n row.rowStatus = 'error';\n row.reason = 'invalid_or_unexpected_result';\n state.stopReason = row.reason;\n } else {\n row.rowStatus = response.status === 'potential_match' ? 'review' : 'no_match';\n row.reason = response.status === 'potential_match' ? 'candidates_need_review' : 'no_candidates_in_selected_coverage';\n row.screeningId = response.id;\n row.screeningStatus = response.status;\n row.candidateCount = response.matches.length;\n row.result = clone(response);\n }\n state.current = null;\n return state;\n}\nfunction finish(state) {\n if (state.current || state.queue.length) throw new Error('unfinished_register');\n return {\n format: 'sanctionskit-supplier-review-demo@1', executionMode: state.mode,\n listId: state.listId, rowCount: state.register.length,\n attempts: state.attempts, maxAttempts: state.maxAttempts, stopReason: state.stopReason,\n counts: state.register.reduce((counts, row) => { counts[row.rowStatus] = (counts[row.rowStatus] ?? 0) + 1; return counts; }, {}),\n storage: 'n8n_execution_output_only', evidenceBundleRetrieved: false,\n limitations: 'No supplier is approved or cleared. No match applies only to the selected synthetic coverage and supplied identity. This run output is not a durable external register or a retained evidence export.',\n rows: state.register,\n };\n}\nfunction fixtureResponse(current) {\n const outcome = current.fixtureOutcome;\n if (outcome !== 'potential_match' && outcome !== 'no_match') {\n return {error: outcome === 'network_error' ? 'SanctionsKit request failed' : `SanctionsKit request failed (${outcome})`};\n }\n return {\n id: `00000000-0000-4000-8000-${String(current.index + 1).padStart(12, '0')}`,\n environment: 'sandbox', status: outcome, createdAt: '2026-10-06T12:00:00.000Z',\n matches: outcome === 'no_match' ? [] : [{\n record: {id: 'synthetic-supplier-001', sourceId: 'sandbox-synthetic', entityType: 'organization',\n authority: 'SanctionsKit synthetic fixture', list: 'Local demonstration only',\n names: [{value: current.body.subject.name, kind: 'primary'}], identifiers: [],\n birthDates: [], addresses: [], nationalities: [], designations: [], extensions: {},\n sourceUrl: 'https://www.sanctionskit.com/docs/onboarding'},\n score: 100,\n evidence: [{field: 'name', queryValue: current.body.subject.name, sourceValue: current.body.subject.name,\n method: 'fixture', contribution: 100, explanation: 'Invented local fixture; not an API screening or real designation.'}],\n conflicts: [],\n }],\n coverage: [{sourceId: 'sandbox-synthetic', version: '1', retrievedAt: '2026-10-06T12:00:00.000Z', fresh: true}],\n versions: {dataset: 'sandbox-synthetic-v1', matchingEngine: 'deterministic-1.1.1', policy: 'review-1.1.0', package: 'sandbox@1'},\n disclaimer: 'Synthetic fixture only. Potential matches require review. No match is limited to selected sources and supplied identity; it is not legal clearance. Scores are not probabilities of wrongdoing.',\n };\n}\nfunction runOffline(input) {\n let state = prepare(input, 'offline_fixture');\n while (next(state).current) state = record(state, fixtureResponse(state.current));\n return finish(state);\n}\n\nreturn [{json: finish($input.first().json), pairedItem: {item: 0}}];" + }, + "id": "supplier-finish", + "name": "Supplier review register", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 990, + 270 + ] + }, + { + "parameters": { + "content": "## Synthetic sandbox adaptation\nInstall n8n-nodes-sanctionskit 0.1.0 and select a sandbox credential on Screen one supplier in sandbox. No credentials are embedded. This version calls the hosted API when executed; only invented supplier rows are provided. Actual outcomes may differ from offline fixtures.\n\nOne attempt at a time; no automatic retry. Shared/unknown failures stop remaining requests. Review the README before adapting. Production screening is paid and is not configured here.", + "height": 300, + "width": 800 + }, + "id": "supplier-notes", + "name": "Read first", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 200, + -360 + ] + } + ], + "connections": { + "Run manually": { + "main": [ + [ + { + "node": "Prepare supplier list", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare supplier list": { + "main": [ + [ + { + "node": "Next supplier row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Next supplier row": { + "main": [ + [ + { + "node": "Attempt or finish", + "type": "main", + "index": 0 + } + ] + ] + }, + "Attempt or finish": { + "main": [ + [ + { + "node": "Screen one supplier in sandbox", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Supplier review register", + "type": "main", + "index": 0 + } + ] + ] + }, + "Screen one supplier in sandbox": { + "main": [ + [ + { + "node": "Record one row outcome", + "type": "main", + "index": 0 + } + ] + ] + }, + "Record one row outcome": { + "main": [ + [ + { + "node": "Next supplier row", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "executionTimeout": 900 + }, + "pinData": {}, + "tags": [] +} diff --git a/integrations/n8n-supplier-review/tests/native-runtime.cjs b/integrations/n8n-supplier-review/tests/native-runtime.cjs new file mode 100644 index 0000000..a77e707 --- /dev/null +++ b/integrations/n8n-supplier-review/tests/native-runtime.cjs @@ -0,0 +1,91 @@ +'use strict'; +// Both variants are imported/exported; ONLY core-node offline fixtures are executed. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); +const crypto = require('node:crypto'); +const root = path.resolve(__dirname,'..'); +const bin = process.env.N8N_BIN; +if (!bin || !path.isAbsolute(bin) || !fs.existsSync(bin)) throw new Error('Set N8N_BIN to an installed absolute n8n/bin/n8n path'); +const runtimeVersion = JSON.parse(fs.readFileSync(path.join(path.dirname(bin),'../package.json'))).version; +assert.equal(runtimeVersion,'2.41.4','Review this check before using another n8n version'); +const directory = fs.mkdtempSync(path.join(os.tmpdir(),'sanctionskit-n8n-supplier-')); +const commands = []; +const env = {...process.env, + N8N_DIAGNOSTICS_ENABLED:'false', N8N_VERSION_NOTIFICATIONS_ENABLED:'false', + N8N_TEMPLATES_ENABLED:'false', N8N_LICENSE_AUTO_RENEW_ENABLED:'false', + N8N_COMMUNITY_PACKAGES_ENABLED:'false', N8N_RUNNERS_MODE:'internal', + N8N_RUNNERS_BROKER_LISTEN_ADDRESS:'127.0.0.1', + N8N_RUNNERS_BROKER_PORT: process.env.N8N_TEST_RUNNER_PORT || '16898', + N8N_LOG_LEVEL:'info', +}; +function run(state,label,args) { + const userFolder = path.join(directory,state); + fs.mkdirSync(userFolder,{recursive:true}); + const r = spawnSync(process.execPath,[bin,...args], {env:{...env,N8N_USER_FOLDER:userFolder},encoding:'utf8',timeout:240000,killSignal:'SIGKILL',maxBuffer:30*1024*1024}); + const output = (r.stdout || '') + (r.stderr || ''); + fs.writeFileSync(path.join(directory,label+'.log'),output); + commands.push({label,args,status:r.status,signal:r.signal}); + if (r.error || r.status !== 0) throw new Error(`${label} failed: ${directory}/${label}.log (${r.error?.message || r.status})`); + return output; +} +for (const mode of ['offline','sandbox']) { + const source = path.join(root,`supplier-review.${mode}.json`); + const workflow = JSON.parse(fs.readFileSync(source)); + assert.equal(workflow.active,false); + assert.ok(workflow.nodes.every(n => !n.credentials)); + const exported = path.join(directory,mode+'-export.json'), roundtrip = path.join(directory,mode+'-roundtrip.json'); + run('first',mode+'-import',['import:workflow','--input='+source]); + run('first',mode+'-export',['export:workflow','--id='+workflow.id,'--output='+exported]); + run('second',mode+'-roundtrip-import',['import:workflow','--input='+exported]); + run('second',mode+'-roundtrip-export',['export:workflow','--id='+workflow.id,'--output='+roundtrip]); + for (const file of [exported,roundtrip]) { + const result = JSON.parse(fs.readFileSync(file)); + assert.equal(result.length,1); + assert.deepEqual(result[0].nodes,workflow.nodes); + assert.deepEqual(result[0].connections,workflow.connections); + assert.equal(result[0].active,false); + assert.deepEqual(result[0].pinData,{}); + } +} +function parseExecution(raw) { + const start = raw.indexOf('{\n "'); + assert.ok(start >= 0,'Missing execution JSON'); + let end = start, depth = 0, quoted = false, escaped = false; + for (; end < raw.length; end++) { + const char = raw[end]; + if (quoted) {if (escaped) escaped=false; else if (char==='\\') escaped=true; else if(char==='"') quoted=false;} + else if(char==='"') quoted=true; + else if(char==='{') depth++; + else if(char==='}' && --depth===0) {end++;break;} + } + return JSON.parse(raw.slice(start,end)); +} +const workflow = require('../supplier-review.offline.json'); +assert.ok(workflow.nodes.every(n => ['manualTrigger','code','switch','stickyNote'].some(type=>n.type==='n8n-nodes-base.'+type))); +const raw = run('second','offline-execute',['execute','--id='+workflow.id,'--rawOutput']); +const execution = parseExecution(raw); +assert.equal(execution.status,'success'); +assert.equal(execution.data.resultData.error,undefined); +const runs = execution.data.resultData.runData; +assert.equal(runs['Supplier review register'].length,1); +const actual = runs['Supplier review register'][0].data.main[0]; +assert.equal(actual.length,1); +const expected = require('../fixtures/example-register.json'); +assert.deepEqual(actual[0].json,expected); +assert.equal(runs['Synthetic response only'].length,expected.attempts); +assert.equal(runs['Record one row outcome'].length,expected.attempts); +assert.equal(runs['Next supplier row'].length,expected.attempts+1); +assert.ok(!runs['Screen one supplier in sandbox']); +const report = { + checkedAt:new Date().toISOString(),nodeVersion:process.version,n8nVersion:runtimeVersion,directory, + nativeExecuted:'offline fixture only',sandboxExecuted:false,hostedApiCalls:0, + importsAndExports:'both variants round-tripped through two fresh local n8n states with graph preserved', + actualFinalNode:'Supplier review register',rowCount:expected.rowCount,attempts:expected.attempts,counts:expected.counts, + sourceSha256:Object.fromEntries(['offline','sandbox'].map(mode=>[mode,crypto.createHash('sha256').update(fs.readFileSync(path.join(root,`supplier-review.${mode}.json`))).digest('hex')])), + commands, +}; +fs.writeFileSync(path.join(directory,'report.json'),JSON.stringify(report,null,2)+'\n'); +console.log(JSON.stringify(report,null,2)); diff --git a/integrations/n8n-supplier-review/tests/register.test.cjs b/integrations/n8n-supplier-review/tests/register.test.cjs new file mode 100644 index 0000000..e214882 --- /dev/null +++ b/integrations/n8n-supplier-review/tests/register.test.cjs @@ -0,0 +1,197 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const {prepare, next, record, finish, runOffline, fixtureResponse, requestFor} = require('../functions/register.cjs'); +const fixture = require('../fixtures/suppliers.json'); +const copy = x => JSON.parse(JSON.stringify(x)); +const row = (id, outcome = 'no_match') => ({supplierId: id, revision: 1, name: `Example ${id} Ltd`, fixtureOutcome: outcome}); +const input = (...rows) => ({listId: 'test-list-v1', maxAttempts: 6, rows}); + +test('mixed run retains all nine rows, counts and screening evidence without decisions', () => { + const result = runOffline(fixture); + assert.equal(result.rowCount, 9); + assert.equal(result.attempts, 4); + assert.deepEqual(result.counts, {review: 1, no_match: 1, duplicate: 1, error: 4, unavailable: 2}); + assert.deepEqual(result.rows.map(r => r.rowNumber), [1,2,3,4,5,6,7,8,9]); + assert.equal(result.rows[0].result.coverage[0].fresh, true); + assert.equal(result.rows[0].result.matches.length, 1); + assert.equal(result.rows[1].result.matches.length, 0); + assert.equal(result.rows[8].attempted, false); + assert.equal(result.rows[8].reason, 'run_stopped:coverage_unavailable'); + assert.ok(result.rows.every(r => r.businessDecision === 'not_decided')); + assert.equal(result.evidenceBundleRetrieved, false); + assert.equal(result.storage, 'n8n_execution_output_only'); +}); +test('identical duplicate skips a second request without pretending to have its own result', () => { + const r = row('same'); const output = runOffline(input(r, copy(r))); + assert.equal(output.attempts, 1); + assert.equal(output.rows[1].rowStatus, 'duplicate'); + assert.equal(output.rows[1].duplicateOfRow, 1); + assert.equal(output.rows[1].screeningId, null); +}); +test('conflicting duplicate group is rejected before either member gets a request', () => { + const r = row('same'); const output = runOffline(input(r, {...r, name: 'Changed Example Ltd'})); + assert.equal(output.attempts, 0); + assert.ok(output.rows.every(r => r.reason === 'duplicate_key_conflict')); +}); +test('invalid row stays visible while later valid row can run', () => { + const output = runOffline(input({...row('bad'), name: 'X'}, row('good'))); + assert.equal(output.rows[0].reason, 'invalid_supplier_row'); + assert.equal(output.rows[0].attempted, false); + assert.equal(output.rows[1].rowStatus, 'no_match'); +}); +test('stable key and body are independent of order and workflow execution ID', () => { + const a = row('a'), b = row('b'); + const first = prepare(input(a,b), 'sandbox_api'); + const second = prepare(input(b,a), 'sandbox_api'); + assert.deepEqual(first.queue[0].body, second.queue[1].body); + assert.equal(first.queue[0].rowKey, second.queue[1].rowKey); + assert.notEqual(requestFor('test-list-v1', {...a, revision: 2}).rowKey, first.queue[0].rowKey); + assert.deepEqual(first.queue[0].body.subject.identifiers, []); +}); +test('maxAttempts bounds requests and preserves the remaining rows', () => { + const output = runOffline({...input(row('a'),row('b'),row('c')), maxAttempts: 1}); + assert.equal(output.attempts, 1); + assert.equal(output.rows[1].reason, 'run_attempt_limit'); + assert.equal(output.rows[2].attempted, false); +}); +for (const code of ['usage_cap_reached','rate_limited','coverage_unavailable','screening_policy_required','invalid_api_key']) { + test(`${code} stops further attempts and is never a no-match`, () => { + const output = runOffline(input(row('a',code),row('b'))); + assert.equal(output.attempts, 1); + assert.equal(output.rows[0].rowStatus, 'unavailable'); + assert.equal(output.rows[1].reason, `run_stopped:${code}`); + }); +} +test('unknown network failure stops safely, keeps no raw message and never clears a row', () => { + let state = next(prepare(input(row('a'),row('b')), 'sandbox_api')); + state = record(state, {error: 'unexpected private subject or token must not survive'}); + const output = finish(next(state)); + assert.equal(output.rows[0].reason, 'request_failed'); + assert.equal(output.rows[0].rowStatus, 'error'); + assert.equal(output.rows[1].attempted, false); + assert.ok(!JSON.stringify(output).includes('private subject')); +}); +for (const [label, mutation] of [ + ['production environment', r => {r.environment = 'production';}], + ['missing coverage', r => {r.coverage = [];}], + ['stale coverage', r => {r.coverage[0].fresh = false;}], + ['foreign coverage', r => {r.coverage[0].sourceId = 'other-source';}], + ['inconsistent status', r => {r.status = 'potential_match';}], + ['missing version', r => {delete r.versions.dataset;}], +]) { + test(`${label} becomes an error and stops the run`, () => { + const state = next(prepare(input(row('a'),row('b')), 'sandbox_api')); + const response = fixtureResponse({...state.current, fixtureOutcome: 'no_match'}); + mutation(response); + const output = finish(next(record(state, response))); + assert.equal(output.rows[0].reason, 'invalid_or_unexpected_result'); + assert.equal(output.rows[0].screeningId, null); + assert.equal(output.rows[1].attempted, false); + }); +} +test('empty list produces an honest zero-row output', () => { + const output = runOffline(input()); + assert.equal(output.rowCount, 0); assert.equal(output.attempts, 0); +}); +test('configuration and progress invariants reject malformed input and unrecorded attempts', () => { + for (const bad of [{...input(),listId:'with:separator'}, {...input(),maxAttempts:21}, {...input(),rows:Array(101).fill(row('a'))}]) assert.throws(() => prepare(bad,'offline_fixture')); + const state = next(prepare(input(row('a')), 'offline_fixture')); + assert.throws(() => next(state), /previous_attempt/); + assert.throws(() => finish(state), /unfinished/); +}); +test('sandbox workflow has genuine node contract, fixed synthetic coverage and no embedded credentials', () => { + const workflow = require('../supplier-review.sandbox.json'); + const screen = workflow.nodes.find(n => n.type === 'n8n-nodes-sanctionskit.sanctionsKit'); + assert.equal(screen.typeVersion, 1); + assert.equal(screen.parameters.resource, 'screening'); + assert.equal(screen.parameters.operation, 'create'); + assert.equal(screen.parameters.coverage, 'sandbox'); + assert.equal(screen.parameters.simplify, false); + assert.equal(screen.parameters.options.identifiers, '[]'); + assert.equal(screen.onError, 'continueRegularOutput'); + assert.equal(screen.retryOnFail, false); + assert.equal(screen.credentials, undefined); + assert.equal(workflow.active, false); + assert.deepEqual(workflow.pinData, {}); + assert.ok(!workflow.nodes.find(n => n.name === 'Prepare supplier list').parameters.jsCode.includes('"fixtureOutcome"')); +}); +test('both graphs have exactly one terminal register and a feedback edge through the recorder', () => { + for (const filename of ['supplier-review.offline.json','supplier-review.sandbox.json']) { + const workflow = JSON.parse(fs.readFileSync(path.join(__dirname,'..',filename))); + assert.equal(workflow.connections['Record one row outcome'].main[0][0].node, 'Next supplier row'); + assert.equal(workflow.connections['Attempt or finish'].main[1][0].node, 'Supplier review register'); + assert.equal(workflow.connections['Supplier review register'], undefined); + assert.ok(workflow.id); + } +}); + +test('published node 0.1.0 executes workflow parameters against an intercepted helper', {skip: !process.env.SANCTIONSKIT_NODE_PACKAGE}, async () => { + const vm = require('node:vm'); + const packageRoot = path.resolve(process.env.SANCTIONSKIT_NODE_PACKAGE); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'))).version, '0.1.0'); + const {SanctionsKit} = require(path.join(packageRoot,'dist/nodes/SanctionsKit/SanctionsKit.node.js')); + const screen = require('../supplier-review.sandbox.json').nodes.find(n => n.type === 'n8n-nodes-sanctionskit.sanctionsKit'); + let state = prepare(fixture, 'offline_fixture'); + let calls = 0; + while (next(state).current) { + const current = copy(state); + const resolve = value => { + if (typeof value === 'string' && value.startsWith('={{') && value.endsWith('}}')) return vm.runInNewContext(value.slice(3,-2), {$json: current}); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k,v]) => [k,resolve(v)])); + return value; + }; + const output = (await new SanctionsKit().execute.call({ + getInputData: () => [{json: current}], getNode: () => screen, + getCredentials: async () => ({environment:'sandbox', apiKey:'sk_test_fixture_not_a_real_key'}), + continueOnFail: () => true, + getNodeParameter: (key, i, fallback) => screen.parameters[key] === undefined ? fallback : resolve(screen.parameters[key]), + helpers: {httpRequestWithAuthentication: async (type, request) => { + calls++; + assert.equal(type,'sanctionsKitApi'); + assert.equal(request.url,'https://www.sanctionskit.com/api/v1/screenings'); + assert.equal(request.headers['Idempotency-Key'], current.current.rowKey); + assert.deepEqual(request.body, current.current.body); + assert.equal(request.disableFollowRedirect, true); + const response = fixtureResponse(current.current); + if (response.error) throw {statusCode: 400, response:{data:{error:{code: current.current.fixtureOutcome}}}}; + return {data: response}; + }}, + }))[0]; + assert.equal(output.length,1); + assert.equal(output[0].pairedItem.item,0); + state = record(state, output[0].json); + } + assert.equal(calls,4); + assert.deepEqual(finish(state),runOffline(fixture)); +}); + +test('non-string supplier and list IDs cannot be coerced into valid identity', () => { + for (const id of [123,['supplier-a'],null,{}]) { + const output = runOffline(input({...row('a'),supplierId:id})); + assert.equal(output.attempts,0); + assert.equal(output.rows[0].reason,'invalid_supplier_row'); + } + for (const listId of [123,['list-a'],null,{}]) assert.throws(()=>prepare({...input(),listId},'offline_fixture')); +}); +test('malformed candidates are errors rather than valid review results', () => { + for (const match of [null,{}, {record:{},score:100,evidence:[],conflicts:[]}]) { + const state = next(prepare(input(row('a')),'offline_fixture')); + const response = fixtureResponse({...state.current,fixtureOutcome:'potential_match'}); + response.matches=[match]; + assert.equal(finish(next(record(state,response))).rows[0].reason,'invalid_or_unexpected_result'); + } +}); +test('workflow timeout covers the supported maximum HTTP attempt budget with overhead', () => { + const workflow=require('../supplier-review.sandbox.json'); + assert.ok(workflow.settings.executionTimeout > 20 * 30 + 60); +}); + +test('invalid payload sharing a valid row key blocks the entire ambiguous group', () => { + const a=row('same'); + const result=runOffline(input(a,{...a,name:'X'})); + assert.equal(result.attempts,0); + assert.ok(result.rows.every(r=>r.reason==='duplicate_key_conflict')); +}); diff --git a/integrations/n8n/README.md b/integrations/n8n/README.md new file mode 100644 index 0000000..dfc2002 --- /dev/null +++ b/integrations/n8n/README.md @@ -0,0 +1,77 @@ +# Route onboarding screenings for review in n8n + +Turn a fictional onboarding event into a screening request, retrieve the evidence for the same result, and keep four outcomes separate. Every output retains `onboardingDecision: "not_made"`. The example never approves a customer or treats a screening result as legal clearance. + +## Choose an import + +| Workflow | What it does | Requirements | +| --- | --- | --- | +| [Offline demonstration](onboarding-review.offline.json) | Runs ten invented cases through the actual Code and Switch nodes. No network requests. | n8n; no SanctionsKit account, node package, or credentials | +| [Synthetic sandbox workflow](onboarding-review.sandbox.json) | Screens one invented person and retrieves the retained evidence using SanctionsKit nodes. | n8n, `n8n-nodes-sanctionskit@0.1.0`, your own sandbox credential | + +Import the JSON through n8n's **Import from File** action, then choose **Execute workflow**. The workflows are inactive and use a manual trigger. Open the final nodes to inspect the records. The offline demonstration deliberately fabricates successful and failed responses; it does not predict a live response for any name. + +For the sandbox version, [create a SanctionsKit workspace](https://www.sanctionskit.com/signup) and a [sandbox key](https://www.sanctionskit.com/dashboard/keys?environment=sandbox) with `sources:read`, `screenings:write`, and `results:read`. The credential's **Test** action reads the source catalog and requires `sources:read`; the workflow creates screenings with `screenings:write` and retrieves evidence with `results:read`. Store the key in an n8n **SanctionsKit API** credential with environment **Sandbox**, then select that credential on both SanctionsKit nodes. No credential is embedded in either export. Install the package using the [n8n integration guide](https://www.sanctionskit.com/integrations/n8n) and the installation options available on your n8n instance. Approval as a verified node and availability in your instance are separate; use the package only where your instance supports it. + +The free sandbox uses synthetic records. Production screening is paid and requires production credentials and explicitly selected, available coverage. This workflow intentionally fixes coverage to `sandbox@1` and rejects results from other environments. + +## What moves through the workflow + +1. **Synthetic onboarding events** supplies an invented `Alex Morgan` person and a stable event ID. +2. **Map onboarding event** derives `n8n:` as the request key, uses the event ID as the retained reference, and requests standard retention. It accepts a name and an optional known birth year; it does not invent missing identity details. Invalid input stops before the API node. +3. **Screen synthetic subject** creates a screening with full output. No automatic retry is configured. +4. **Classify screening response** validates the result and sends failed or unavailable requests directly to an incomplete branch. +5. **Get same screening evidence** retrieves the exact returned screening ID. **Build review record** verifies that the evidence agrees with the original result, event reference, submitted subject, and retained request. It preserves the complete result and evidence document, including candidate evidence, versions, freshness, notices, and interpretation limits. +6. **Route review record** sends each event to one of four inspection nodes. + +| Final node | Meaning | +| --- | --- | +| Potential match — human review | Candidate records need review. The evidence document is present. | +| No match — apply organization policy | No candidates were returned within the selected coverage and matching rules. Apply your own decision policy; the workflow makes no approval. | +| Request or evidence error — incomplete | Authentication, idempotency conflict, expired evidence, malformed data, mismatched evidence, and unrecognized failures remain incomplete. | +| Coverage unavailable — incomplete | Unavailable/stale coverage, missing coverage, or freshness failure cannot become a no-match result. | + +All four final nodes are **No Operation** inspection endpoints. They do not create a review case, write to a CRM, send notifications, or persist a queue. n8n may retain execution data according to its own settings; that is not a durable business decision record. A successful n8n execution means the routing completed, not that onboarding or all screenings succeeded. Inspect each record's `route` and `stage`. + +The v0.1.0 SanctionsKit node's continued errors contain a sanitized `error` message, not a structured HTTP status and code. This example recognizes only the exact documented-in-code message pattern for `coverage_unavailable` and `stale_coverage`; every unknown error remains `request_error`. On a node upgrade, rerun the contract tests before relying on that classification. + +## Stable events and retries + +The event ID identifies one immutable screening request. Keep the same event ID, request key, and body when retrying the same request. The mapping is deterministic and does not use an execution ID, timestamp, or fresh UUID. A changed request must have a new event ID/version. Repeating an unchanged ID with a changed body can return `idempotency_conflict`; this remains incomplete. + +This example has no durable deduplication store and makes no exactly-once claim. A production event handler must persist the intended request and key before dispatch and recover that same intent after timeouts. It must not issue a new key simply because the first attempt has an uncertain outcome. See [idempotency](https://www.sanctionskit.com/docs/idempotency). + +## Adapt for a real onboarding system + +Before connecting real people or organizations: + +- Replace the synthetic manual source with an authenticated, validated event source. Define a durable event ID and immutable request record. The example does not implement an authenticated webhook. +- Map only known identity attributes; expand the deliberately small input validator for your supported subject types and date precision. +- Select production credentials, production coverage, and any approved policy your workspace requires. Update the sandbox-only result and evidence checks deliberately. Check source freshness, rights, and entity-type support; do not fall back to different coverage silently. +- Replace the four inspection nodes with access-controlled review and incomplete-request handling. Persist the full result/evidence, original event reference, and separate decision history. Define who may make the actual onboarding decision. +- Design explicit retries and recovery for incomplete requests while retaining the original key and body. Evidence-fetch recovery should reuse the existing screening ID; it need not create another screening. +- Set appropriate n8n execution-data retention, permissions, and logging for real subject details. Do not copy real screening data into fixtures or public issue reports. + +Read [screenings](https://www.sanctionskit.com/docs/screenings), [error handling](https://www.sanctionskit.com/docs/errors), [retention](https://www.sanctionskit.com/docs/retention), and [pricing](https://www.sanctionskit.com/pricing) when adapting the template. + +## Local checks + +Node.js 24 or later; no dependencies needed for the core checks: + +```sh +npm --prefix integrations/n8n run build +npm --prefix integrations/n8n test +``` + +The tests cover all ten fixture routes, deterministic keys, rejected inputs, unknown errors, cross-environment results, incomplete coverage, result/evidence identity, preserved item links, and credential-free exports. The JSON files are generated from `build.cjs`, `functions/review.cjs`, and synthetic fixtures; edit those sources and rebuild. + +To additionally test the actual published node, point `SANCTIONSKIT_NODE_PACKAGE` at an unpacked **0.1.0** package and make its `n8n-workflow` dependency resolvable (for example, set `NODE_PATH` to an installed n8n runtime's `node_modules`). Run the same test command. This test intercepts the node's HTTP helper: its fake key is only an in-memory fixture marker and no hosted API requests occur. + +For native import/export and offline execution with an existing n8n 2.41.4 installation: + +```sh +N8N_BIN=/absolute/path/to/node_modules/n8n/bin/n8n \ + node integrations/n8n/tests/native-runtime.cjs +``` + +The native check creates isolated temporary n8n state, imports and exports both workflows, round-trips the export into separate clean state, and executes only the offline workflow. It needs permission to bind the local task-runner port. It never selects credentials or executes the sandbox workflow. Its report prints the temporary evidence directory. diff --git a/integrations/n8n/build.cjs b/integrations/n8n/build.cjs new file mode 100644 index 0000000..c1204cf --- /dev/null +++ b/integrations/n8n/build.cjs @@ -0,0 +1,47 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const root = __dirname; +const source = file => fs.readFileSync(path.join(root, file), 'utf8').replace(/\nmodule\.exports = [^\n]+;\s*$/, '\n'); +const review = source('functions/review.cjs'); +const fixtureFns = source('tests/fixtures/responses.cjs'); +const base = require('./tests/fixtures/base-evidence.json'); +const cases = require('./tests/fixtures/cases.json'); +const event = { synthetic: true, eventId: 'onboarding:demo-person-001:v1', subject: { name: 'Alex Morgan', entityType: 'person', birthDate: '1984' } }; +const codeNode = (name, position, jsCode) => ({ parameters: { mode: 'runOnceForAllItems', jsCode }, id: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name, type: 'n8n-nodes-base.code', typeVersion: 2, position }); +const noOp = (name, position) => ({ parameters: {}, id: name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name, type: 'n8n-nodes-base.noOp', typeVersion: 1, position }); +const edge = name => ({ node: name, type: 'main', index: 0 }); + +function workflow(offline) { + const nodes = [ + { parameters: {}, id: 'manual-trigger', name: 'Manual demonstration', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [0, 200] }, + codeNode('Synthetic onboarding events', [220, 200], 'return ' + JSON.stringify(offline ? cases.map(c => ({ ...event, eventId: 'onboarding:fixture:' + c.case + ':v1', fixtureCase: c.case })) : [event], null, 2) + '.map(json => ({json}));'), + codeNode('Map onboarding event', [440, 200], review + '\nreturn $input.all().map((item, i) => ({ json: mapEvent(item.json), pairedItem: { item: i } }));'), + offline + ? codeNode('Screen synthetic subject', [660, 200], fixtureFns + '\nconst base = ' + JSON.stringify(base) + ';\nreturn $input.all().map((item, i) => ({ json: fixtureScreening(item.json, base), pairedItem: { item: i } }));') + : { parameters: { resource: 'screening', operation: 'create', name: '={{ $json.request.subject.name }}', entityType: 'person', coverage: 'sandbox', idempotencyKey: '={{ $json.requestKey }}', options: { identifiers: '[]', birthDate: '={{ $json.request.subject.birthDate || "" }}', reference: '={{ $json.request.reference }}', retention: 'standard' }, simplify: false }, id: 'screen-synthetic-subject', name: 'Screen synthetic subject', type: 'n8n-nodes-sanctionskit.sanctionsKit', typeVersion: 1, position: [660, 200], onError: 'continueRegularOutput', notesInFlow: true, notes: 'Select your own sandbox credential. No credential is included. One attempt per item.' }, + codeNode('Classify screening response', [880, 200], review + '\nreturn $input.all().map((item, i) => ({ json: classifyScreening(item.json, $("Map onboarding event").itemMatching(i).json), pairedItem: { item: i } }));'), + { parameters: { mode: 'expression', numberOutputs: 2, output: '={{ $json.route === "fetch_evidence" ? 0 : 1 }}' }, id: 'fetch-or-incomplete', name: 'Fetch evidence or incomplete', type: 'n8n-nodes-base.switch', typeVersion: 3.4, position: [1100, 200] }, + offline + ? codeNode('Get same screening evidence', [1320, 100], fixtureFns + '\nconst base = ' + JSON.stringify(base) + ';\nreturn $input.all().map((item, i) => ({ json: fixtureEvidence(item.json, base), pairedItem: { item: i } }));') + : { parameters: { resource: 'screening', operation: 'getEvidence', screeningId: '={{ $json.screeningId }}' }, id: 'get-same-screening-evidence', name: 'Get same screening evidence', type: 'n8n-nodes-sanctionskit.sanctionsKit', typeVersion: 1, position: [1320, 100], onError: 'continueRegularOutput', notesInFlow: true, notes: 'Use the same sandbox credential. Evidence errors remain incomplete.' }, + codeNode('Build review record', [1540, 100], review + '\nreturn $input.all().map((item, i) => ({ json: finishEvidence(item.json, $("Classify screening response").itemMatching(i).json), pairedItem: { item: i } }));'), + { parameters: { mode: 'expression', numberOutputs: 4, output: '={{ ({review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3})[$json.route] ?? 2 }}' }, id: 'route-review-record', name: 'Route review record', type: 'n8n-nodes-base.switch', typeVersion: 3.4, position: [1770, 250] }, + noOp('Potential match - human review', [2030, 0]), + noOp('No match - apply organization policy', [2030, 170]), + noOp('Request or evidence error - incomplete', [2030, 340]), + noOp('Coverage unavailable - incomplete', [2030, 510]), + { parameters: { content: offline + ? '## OFFLINE synthetic onboarding review\nTen invented fixture cases exercise all four routes. No API call, credentials, or real subjects. This is a local demonstration, not live screening. Open the four final nodes to inspect the records. All decisions remain **not_made**.\n\nThe separate sandbox workflow uses n8n-nodes-sanctionskit@0.1.0 and requires your own sandbox credential. Sandbox is free and synthetic; production screening is paid.' + : '## Sandbox onboarding review\nUse n8n-nodes-sanctionskit@0.1.0 and select your own **sandbox** credential on both SanctionsKit nodes. Alex Morgan is invented. Coverage is fixed to sandbox@1. This template makes API requests only when you run it.\n\nPotential matches require human review. No match is limited to selected coverage and never approves onboarding. No durable queue, webhook, retry, or customer update is configured. Production requires deliberate adaptation; see README.', height: 300, width: 1040 }, id: 'read-first', name: 'Read first', type: 'n8n-nodes-base.stickyNote', typeVersion: 1, position: [0, -170] }, + ]; + const names = ['Manual demonstration', 'Synthetic onboarding events', 'Map onboarding event', 'Screen synthetic subject', 'Classify screening response', 'Fetch evidence or incomplete']; + const connections = {}; + for (let i = 0; i < names.length - 1; i++) connections[names[i]] = { main: [[edge(names[i + 1])]] }; + connections['Fetch evidence or incomplete'] = { main: [[edge('Get same screening evidence')], [edge('Route review record')]] }; + connections['Get same screening evidence'] = { main: [[edge('Build review record')]] }; + connections['Build review record'] = { main: [[edge('Route review record')]] }; + connections['Route review record'] = { main: ['Potential match - human review', 'No match - apply organization policy', 'Request or evidence error - incomplete', 'Coverage unavailable - incomplete'].map(n => [edge(n)]) }; + return { id: offline ? 'SanctionsKitOfflineReview' : 'SanctionsKitSandboxReview', name: 'SanctionsKit onboarding review - ' + (offline ? 'OFFLINE fixtures' : 'synthetic sandbox'), nodes, pinData: {}, connections, active: false, settings: { executionOrder: 'v1' }, tags: [] }; +} + +for (const offline of [true, false]) fs.writeFileSync(path.join(root, 'onboarding-review.' + (offline ? 'offline' : 'sandbox') + '.json'), JSON.stringify(workflow(offline), null, 2) + '\n'); diff --git a/integrations/n8n/functions/review.cjs b/integrations/n8n/functions/review.cjs new file mode 100644 index 0000000..6f4c5fe --- /dev/null +++ b/integrations/n8n/functions/review.cjs @@ -0,0 +1,95 @@ +// Inlined into the importable workflows by build.cjs. No network or filesystem access. +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function canonical(value) { + if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'; + if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}'; + return JSON.stringify(value); +} + +function mapEvent(event) { + // This example intentionally accepts only its explicitly synthetic input shape. + // Invalid input stops before the API node; it is never a no-match outcome. + if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) { + throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters'); + } + if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') { + throw new Error('This example requires a fictional person with a name'); + } + if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\d{4}$/.test(event.subject.birthDate))) { + throw new Error('This example accepts only a known birth year, or omit birthDate'); + } + const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] }; + if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate; + return { + eventId: event.eventId, + requestKey: 'n8n:' + event.eventId, + request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' }, + onboardingDecision: 'not_made', + ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}), + }; +} + +function failure(context, stage, errorCode, route = 'request_error') { + return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' }; +} + +function nodeFailure(response, context, stage) { + if (!isObject(response) || !Object.hasOwn(response, 'error')) return null; + // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object. + // Recognize only that exact format. Unrecognized errors remain incomplete requests. + const match = typeof response.error === 'string' && /^SanctionsKit request failed \(([a-z_]{1,80})\)$/.exec(response.error); + const code = match ? match[1] : 'unclassified_node_error'; + return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error'); +} + +function resultIssue(result) { + if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) { + return 'invalid_result'; + } + if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package'; + if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result'; + if (!result.matches.every(candidate => { + if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false; + if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false; + const record = candidate.record; + return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions); + })) return 'invalid_candidate_evidence'; + if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable'; + return null; +} + +function classifyScreening(response, context) { + const failed = nodeFailure(response, context, 'screening'); + if (failed) return failed; + const issue = resultIssue(response); + if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error'); + return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id }; +} + +function finishEvidence(evidence, context) { + const failed = nodeFailure(evidence, context, 'evidence'); + if (failed) return failed; + if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence'); + const issue = resultIssue(evidence.result); + if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error'); + // Preserve the entire document, but compare all original result fields to the + // result that triggered this fetch. Object key order is immaterial. + if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch'); + if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch'); + return { + ...context, + route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review', + stage: 'evidence_complete', + evidence, + onboardingDecision: 'not_made', + }; +} + +function outputIndex(route) { + return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2; +} + +module.exports = { mapEvent, classifyScreening, finishEvidence, outputIndex, resultIssue }; diff --git a/integrations/n8n/onboarding-review.offline.json b/integrations/n8n/onboarding-review.offline.json new file mode 100644 index 0000000..0b02002 --- /dev/null +++ b/integrations/n8n/onboarding-review.offline.json @@ -0,0 +1,325 @@ +{ + "id": "SanctionsKitOfflineReview", + "name": "SanctionsKit onboarding review - OFFLINE fixtures", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger", + "name": "Manual demonstration", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "return [\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:potential_match:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"potential_match\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:coverage_unavailable:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"coverage_unavailable\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:no_match:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"no_match\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:invalid_api_key:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"invalid_api_key\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:stale_coverage:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"stale_coverage\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:evidence_error:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"evidence_error\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:idempotency_conflict:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"idempotency_conflict\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:evidence_wrong_id:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"evidence_wrong_id\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:no_match_missing_coverage:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"no_match_missing_coverage\"\n },\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:fixture:unknown_error_shape:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n },\n \"fixtureCase\": \"unknown_error_shape\"\n }\n].map(json => ({json}));" + }, + "id": "synthetic-onboarding-events", + "name": "Synthetic onboarding events", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: mapEvent(item.json), pairedItem: { item: i } }));" + }, + "id": "map-onboarding-event", + "name": "Map onboarding event", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Pure fixtures. build.cjs inlines these only into the explicitly OFFLINE workflow.\nfunction fixtureScreening(context, baseEvidence) {\n const name = context.fixtureCase;\n if (['coverage_unavailable', 'stale_coverage', 'invalid_api_key', 'idempotency_conflict'].includes(name)) return { error: `SanctionsKit request failed (${name})` };\n if (name === 'unknown_error_shape') return { error: { code: 'coverage_unavailable' } };\n const result = JSON.parse(JSON.stringify(baseEvidence.result));\n if (name.startsWith('no_match')) { result.status = 'no_match'; result.matches = []; }\n if (name === 'no_match_missing_coverage') result.coverage = [];\n return result;\n}\n\nfunction fixtureEvidence(context, baseEvidence) {\n if (context.fixtureCase === 'evidence_error') return { error: 'SanctionsKit request failed (result_expired)' };\n const evidence = JSON.parse(JSON.stringify(baseEvidence));\n evidence.result = JSON.parse(JSON.stringify(context.result));\n evidence.subject = context.request.subject;\n evidence.reference = context.eventId;\n evidence.request = context.request;\n if (context.fixtureCase === 'evidence_wrong_id') evidence.result.id = '00000000-0000-4000-8000-000000000099';\n return evidence;\n}\n\n\nconst base = {\"format\":\"sanctionskit-evidence@1\",\"result\":{\"id\":\"00000000-0000-4000-8000-000000000001\",\"status\":\"potential_match\",\"matches\":[{\"score\":96,\"record\":{\"id\":\"sandbox:person-001\",\"list\":\"Synthetic demonstration records\",\"names\":[{\"kind\":\"primary\",\"value\":\"Alex Morgan\"},{\"kind\":\"alias\",\"value\":\"Алекс Морган\"}],\"sourceId\":\"sandbox-synthetic\",\"addresses\":[],\"authority\":\"SanctionsKit synthetic examples\",\"sourceUrl\":\"https://sanctionskit.com/demo\",\"birthDates\":[{\"value\":\"1984\",\"original\":\"1984\",\"precision\":\"year\"}],\"entityType\":\"person\",\"extensions\":{\"synthetic\":true},\"identifiers\":[{\"type\":\"passport\",\"value\":\"DEMO-001\",\"issuer\":\"Fictional authority\"}],\"designations\":[{\"wording\":\"Invented example, not a government designation.\",\"category\":\"other\",\"programs\":[\"Synthetic demonstration only\"],\"legalReferences\":[]}],\"nationalities\":[]},\"evidence\":[{\"field\":\"name\",\"method\":\"normalized_name_exact\",\"queryValue\":\"Alex Morgan\",\"explanation\":\"Name similarity is a review signal, not a probability or finding of wrongdoing.\",\"sourceValue\":\"Alex Morgan\",\"contribution\":96}],\"conflicts\":[],\"assessment\":{\"name\":{\"method\":\"normalized_name_exact\",\"aliasKind\":\"primary\",\"similarity\":96,\"aliasQuality\":\"unknown\"},\"country\":{\"status\":\"not_supplied\"},\"birthDate\":{\"status\":\"not_supplied\"},\"alertBasis\":\"name\",\"entityType\":{\"status\":\"agreement\"},\"reviewTier\":\"name_only\",\"identifiers\":{\"status\":\"not_supplied\",\"matched\":0,\"conflicts\":0,\"issuerUnknown\":0,\"issuerAgreements\":0},\"hasConflicts\":false,\"sameNameCandidates\":1}}],\"coverage\":[{\"fresh\":true,\"version\":\"1\",\"sourceId\":\"sandbox-synthetic\",\"retrievedAt\":\"2026-09-17T12:00:00.000Z\"}],\"matching\":{\"profile\":\"standard\",\"version\":\"review-1.2.0\",\"nameThreshold\":78,\"retrievalVersion\":\"indexed-1.1.1\",\"corroboratedNameThreshold\":75},\"versions\":{\"policy\":\"review-1.2.0\",\"dataset\":\"sandbox-synthetic-v1\",\"package\":\"sandbox@1\",\"matchingEngine\":\"deterministic-1.2.0\"},\"createdAt\":\"2026-09-17T12:00:00.000Z\",\"disclaimer\":\"Potential matches support analyst review. No match means no match in the selected sources and dataset, not legal clearance. Scores and evidence categories support review; they are not probabilities of identity or wrongdoing.\",\"environment\":\"sandbox\"},\"retention\":\"standard\",\"expires_at\":null,\"subject\":{\"name\":\"Alex Morgan\",\"entityType\":\"person\",\"birthDate\":\"1984\"},\"reference\":\"example-customer-001\",\"request\":{\"subject\":{\"name\":\"Alex Morgan\",\"entityType\":\"person\",\"birthDate\":\"1984\"},\"package\":\"sandbox@1\",\"reference\":\"example-customer-001\",\"retention\":\"standard\"},\"retainedInputs\":true,\"replayLimit\":null};\nreturn $input.all().map((item, i) => ({ json: fixtureScreening(item.json, base), pairedItem: { item: i } }));" + }, + "id": "screen-synthetic-subject", + "name": "Screen synthetic subject", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: classifyScreening(item.json, $(\"Map onboarding event\").itemMatching(i).json), pairedItem: { item: i } }));" + }, + "id": "classify-screening-response", + "name": "Classify screening response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + 200 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 2, + "output": "={{ $json.route === \"fetch_evidence\" ? 0 : 1 }}" + }, + "id": "fetch-or-incomplete", + "name": "Fetch evidence or incomplete", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 1100, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Pure fixtures. build.cjs inlines these only into the explicitly OFFLINE workflow.\nfunction fixtureScreening(context, baseEvidence) {\n const name = context.fixtureCase;\n if (['coverage_unavailable', 'stale_coverage', 'invalid_api_key', 'idempotency_conflict'].includes(name)) return { error: `SanctionsKit request failed (${name})` };\n if (name === 'unknown_error_shape') return { error: { code: 'coverage_unavailable' } };\n const result = JSON.parse(JSON.stringify(baseEvidence.result));\n if (name.startsWith('no_match')) { result.status = 'no_match'; result.matches = []; }\n if (name === 'no_match_missing_coverage') result.coverage = [];\n return result;\n}\n\nfunction fixtureEvidence(context, baseEvidence) {\n if (context.fixtureCase === 'evidence_error') return { error: 'SanctionsKit request failed (result_expired)' };\n const evidence = JSON.parse(JSON.stringify(baseEvidence));\n evidence.result = JSON.parse(JSON.stringify(context.result));\n evidence.subject = context.request.subject;\n evidence.reference = context.eventId;\n evidence.request = context.request;\n if (context.fixtureCase === 'evidence_wrong_id') evidence.result.id = '00000000-0000-4000-8000-000000000099';\n return evidence;\n}\n\n\nconst base = {\"format\":\"sanctionskit-evidence@1\",\"result\":{\"id\":\"00000000-0000-4000-8000-000000000001\",\"status\":\"potential_match\",\"matches\":[{\"score\":96,\"record\":{\"id\":\"sandbox:person-001\",\"list\":\"Synthetic demonstration records\",\"names\":[{\"kind\":\"primary\",\"value\":\"Alex Morgan\"},{\"kind\":\"alias\",\"value\":\"Алекс Морган\"}],\"sourceId\":\"sandbox-synthetic\",\"addresses\":[],\"authority\":\"SanctionsKit synthetic examples\",\"sourceUrl\":\"https://sanctionskit.com/demo\",\"birthDates\":[{\"value\":\"1984\",\"original\":\"1984\",\"precision\":\"year\"}],\"entityType\":\"person\",\"extensions\":{\"synthetic\":true},\"identifiers\":[{\"type\":\"passport\",\"value\":\"DEMO-001\",\"issuer\":\"Fictional authority\"}],\"designations\":[{\"wording\":\"Invented example, not a government designation.\",\"category\":\"other\",\"programs\":[\"Synthetic demonstration only\"],\"legalReferences\":[]}],\"nationalities\":[]},\"evidence\":[{\"field\":\"name\",\"method\":\"normalized_name_exact\",\"queryValue\":\"Alex Morgan\",\"explanation\":\"Name similarity is a review signal, not a probability or finding of wrongdoing.\",\"sourceValue\":\"Alex Morgan\",\"contribution\":96}],\"conflicts\":[],\"assessment\":{\"name\":{\"method\":\"normalized_name_exact\",\"aliasKind\":\"primary\",\"similarity\":96,\"aliasQuality\":\"unknown\"},\"country\":{\"status\":\"not_supplied\"},\"birthDate\":{\"status\":\"not_supplied\"},\"alertBasis\":\"name\",\"entityType\":{\"status\":\"agreement\"},\"reviewTier\":\"name_only\",\"identifiers\":{\"status\":\"not_supplied\",\"matched\":0,\"conflicts\":0,\"issuerUnknown\":0,\"issuerAgreements\":0},\"hasConflicts\":false,\"sameNameCandidates\":1}}],\"coverage\":[{\"fresh\":true,\"version\":\"1\",\"sourceId\":\"sandbox-synthetic\",\"retrievedAt\":\"2026-09-17T12:00:00.000Z\"}],\"matching\":{\"profile\":\"standard\",\"version\":\"review-1.2.0\",\"nameThreshold\":78,\"retrievalVersion\":\"indexed-1.1.1\",\"corroboratedNameThreshold\":75},\"versions\":{\"policy\":\"review-1.2.0\",\"dataset\":\"sandbox-synthetic-v1\",\"package\":\"sandbox@1\",\"matchingEngine\":\"deterministic-1.2.0\"},\"createdAt\":\"2026-09-17T12:00:00.000Z\",\"disclaimer\":\"Potential matches support analyst review. No match means no match in the selected sources and dataset, not legal clearance. Scores and evidence categories support review; they are not probabilities of identity or wrongdoing.\",\"environment\":\"sandbox\"},\"retention\":\"standard\",\"expires_at\":null,\"subject\":{\"name\":\"Alex Morgan\",\"entityType\":\"person\",\"birthDate\":\"1984\"},\"reference\":\"example-customer-001\",\"request\":{\"subject\":{\"name\":\"Alex Morgan\",\"entityType\":\"person\",\"birthDate\":\"1984\"},\"package\":\"sandbox@1\",\"reference\":\"example-customer-001\",\"retention\":\"standard\"},\"retainedInputs\":true,\"replayLimit\":null};\nreturn $input.all().map((item, i) => ({ json: fixtureEvidence(item.json, base), pairedItem: { item: i } }));" + }, + "id": "get-same-screening-evidence", + "name": "Get same screening evidence", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1320, + 100 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: finishEvidence(item.json, $(\"Classify screening response\").itemMatching(i).json), pairedItem: { item: i } }));" + }, + "id": "build-review-record", + "name": "Build review record", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1540, + 100 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 4, + "output": "={{ ({review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3})[$json.route] ?? 2 }}" + }, + "id": "route-review-record", + "name": "Route review record", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 1770, + 250 + ] + }, + { + "parameters": {}, + "id": "potential-match-human-review", + "name": "Potential match - human review", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 0 + ] + }, + { + "parameters": {}, + "id": "no-match-apply-organization-policy", + "name": "No match - apply organization policy", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 170 + ] + }, + { + "parameters": {}, + "id": "request-or-evidence-error-incomplete", + "name": "Request or evidence error - incomplete", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 340 + ] + }, + { + "parameters": {}, + "id": "coverage-unavailable-incomplete", + "name": "Coverage unavailable - incomplete", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 510 + ] + }, + { + "parameters": { + "content": "## OFFLINE synthetic onboarding review\nTen invented fixture cases exercise all four routes. No API call, credentials, or real subjects. This is a local demonstration, not live screening. Open the four final nodes to inspect the records. All decisions remain **not_made**.\n\nThe separate sandbox workflow uses n8n-nodes-sanctionskit@0.1.0 and requires your own sandbox credential. Sandbox is free and synthetic; production screening is paid.", + "height": 300, + "width": 1040 + }, + "id": "read-first", + "name": "Read first", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + -170 + ] + } + ], + "pinData": {}, + "connections": { + "Manual demonstration": { + "main": [ + [ + { + "node": "Synthetic onboarding events", + "type": "main", + "index": 0 + } + ] + ] + }, + "Synthetic onboarding events": { + "main": [ + [ + { + "node": "Map onboarding event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Map onboarding event": { + "main": [ + [ + { + "node": "Screen synthetic subject", + "type": "main", + "index": 0 + } + ] + ] + }, + "Screen synthetic subject": { + "main": [ + [ + { + "node": "Classify screening response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify screening response": { + "main": [ + [ + { + "node": "Fetch evidence or incomplete", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch evidence or incomplete": { + "main": [ + [ + { + "node": "Get same screening evidence", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Route review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get same screening evidence": { + "main": [ + [ + { + "node": "Build review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build review record": { + "main": [ + [ + { + "node": "Route review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route review record": { + "main": [ + [ + { + "node": "Potential match - human review", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No match - apply organization policy", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Request or evidence error - incomplete", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Coverage unavailable - incomplete", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "tags": [] +} diff --git a/integrations/n8n/onboarding-review.sandbox.json b/integrations/n8n/onboarding-review.sandbox.json new file mode 100644 index 0000000..a4e347a --- /dev/null +++ b/integrations/n8n/onboarding-review.sandbox.json @@ -0,0 +1,343 @@ +{ + "id": "SanctionsKitSandboxReview", + "name": "SanctionsKit onboarding review - synthetic sandbox", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger", + "name": "Manual demonstration", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "return [\n {\n \"synthetic\": true,\n \"eventId\": \"onboarding:demo-person-001:v1\",\n \"subject\": {\n \"name\": \"Alex Morgan\",\n \"entityType\": \"person\",\n \"birthDate\": \"1984\"\n }\n }\n].map(json => ({json}));" + }, + "id": "synthetic-onboarding-events", + "name": "Synthetic onboarding events", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 200 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: mapEvent(item.json), pairedItem: { item: i } }));" + }, + "id": "map-onboarding-event", + "name": "Map onboarding event", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 200 + ] + }, + { + "parameters": { + "resource": "screening", + "operation": "create", + "name": "={{ $json.request.subject.name }}", + "entityType": "person", + "coverage": "sandbox", + "idempotencyKey": "={{ $json.requestKey }}", + "options": { + "identifiers": "[]", + "birthDate": "={{ $json.request.subject.birthDate || \"\" }}", + "reference": "={{ $json.request.reference }}", + "retention": "standard" + }, + "simplify": false + }, + "id": "screen-synthetic-subject", + "name": "Screen synthetic subject", + "type": "n8n-nodes-sanctionskit.sanctionsKit", + "typeVersion": 1, + "position": [ + 660, + 200 + ], + "onError": "continueRegularOutput", + "notesInFlow": true, + "notes": "Select your own sandbox credential. No credential is included. One attempt per item." + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: classifyScreening(item.json, $(\"Map onboarding event\").itemMatching(i).json), pairedItem: { item: i } }));" + }, + "id": "classify-screening-response", + "name": "Classify screening response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + 200 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 2, + "output": "={{ $json.route === \"fetch_evidence\" ? 0 : 1 }}" + }, + "id": "fetch-or-incomplete", + "name": "Fetch evidence or incomplete", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 1100, + 200 + ] + }, + { + "parameters": { + "resource": "screening", + "operation": "getEvidence", + "screeningId": "={{ $json.screeningId }}" + }, + "id": "get-same-screening-evidence", + "name": "Get same screening evidence", + "type": "n8n-nodes-sanctionskit.sanctionsKit", + "typeVersion": 1, + "position": [ + 1320, + 100 + ], + "onError": "continueRegularOutput", + "notesInFlow": true, + "notes": "Use the same sandbox credential. Evidence errors remain incomplete." + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Inlined into the importable workflows by build.cjs. No network or filesystem access.\nfunction isObject(value) {\n return value !== null && typeof value === 'object' && !Array.isArray(value);\n}\n\nfunction canonical(value) {\n if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';\n if (isObject(value)) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';\n return JSON.stringify(value);\n}\n\nfunction mapEvent(event) {\n // This example intentionally accepts only its explicitly synthetic input shape.\n // Invalid input stops before the API node; it is never a no-match outcome.\n if (!isObject(event) || event.synthetic !== true || typeof event.eventId !== 'string' || !/^[A-Za-z0-9_:.-]{4,124}$/.test(event.eventId)) {\n throw new Error('Provide a synthetic event with a stable eventId of 4-124 allowed characters');\n }\n if (!isObject(event.subject) || typeof event.subject.name !== 'string' || event.subject.name.trim().length < 2 || event.subject.name.trim().length > 300 || event.subject.entityType !== 'person') {\n throw new Error('This example requires a fictional person with a name');\n }\n if (event.subject.birthDate !== undefined && (typeof event.subject.birthDate !== 'string' || !/^(?!0000)\\d{4}$/.test(event.subject.birthDate))) {\n throw new Error('This example accepts only a known birth year, or omit birthDate');\n }\n const subject = { name: event.subject.name.trim(), entityType: 'person', identifiers: [] };\n if (event.subject.birthDate !== undefined) subject.birthDate = event.subject.birthDate;\n return {\n eventId: event.eventId,\n requestKey: 'n8n:' + event.eventId,\n request: { subject, package: 'sandbox@1', reference: event.eventId, retention: 'standard' },\n onboardingDecision: 'not_made',\n ...(event.fixtureCase ? { fixtureCase: event.fixtureCase } : {}),\n };\n}\n\nfunction failure(context, stage, errorCode, route = 'request_error') {\n return { ...context, route, stage, errorCode, onboardingDecision: 'not_made' };\n}\n\nfunction nodeFailure(response, context, stage) {\n if (!isObject(response) || !Object.hasOwn(response, 'error')) return null;\n // v0.1.0 continued errors expose a sanitized message, not an HTTP status/code object.\n // Recognize only that exact format. Unrecognized errors remain incomplete requests.\n const match = typeof response.error === 'string' && /^SanctionsKit request failed \\(([a-z_]{1,80})\\)$/.exec(response.error);\n const code = match ? match[1] : 'unclassified_node_error';\n return failure(context, stage, code, ['coverage_unavailable', 'stale_coverage'].includes(code) ? 'coverage_unavailable' : 'request_error');\n}\n\nfunction resultIssue(result) {\n if (!isObject(result) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.id || '') || result.environment !== 'sandbox' || !['potential_match', 'no_match'].includes(result.status) || !Array.isArray(result.matches) || typeof result.createdAt !== 'string' || !Number.isFinite(Date.parse(result.createdAt)) || typeof result.disclaimer !== 'string' || !result.disclaimer.trim() || !isObject(result.versions) || !['dataset', 'matchingEngine', 'policy'].every(k => typeof result.versions[k] === 'string' && result.versions[k])) {\n return 'invalid_result';\n }\n if (result.versions.package !== 'sandbox@1') return 'unexpected_coverage_package';\n if ((result.status === 'no_match' && result.matches.length !== 0) || (result.status === 'potential_match' && result.matches.length === 0)) return 'inconsistent_result';\n if (!result.matches.every(candidate => {\n if (!isObject(candidate) || !Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 100 || !Array.isArray(candidate.evidence) || !Array.isArray(candidate.conflicts) || !candidate.conflicts.every(value => typeof value === 'string')) return false;\n if (!candidate.evidence.every(value => isObject(value) && ['field', 'queryValue', 'sourceValue', 'method', 'explanation'].every(k => typeof value[k] === 'string') && Number.isFinite(value.contribution))) return false;\n const record = candidate.record;\n return isObject(record) && ['id', 'sourceId', 'authority', 'list', 'sourceUrl'].every(k => typeof record[k] === 'string') && ['person', 'organization', 'vessel', 'aircraft', 'other'].includes(record.entityType) && ['names', 'identifiers', 'birthDates', 'addresses', 'nationalities', 'designations'].every(k => Array.isArray(record[k])) && record.names.length > 0 && record.names.every(name => isObject(name) && typeof name.value === 'string' && ['primary', 'alias'].includes(name.kind)) && isObject(record.extensions);\n })) return 'invalid_candidate_evidence';\n if (!Array.isArray(result.coverage) || result.coverage.length === 0 || !result.coverage.every(c => isObject(c) && c.fresh === true && typeof c.sourceId === 'string' && c.sourceId && typeof c.version === 'string' && c.version && typeof c.retrievedAt === 'string' && Number.isFinite(Date.parse(c.retrievedAt)))) return 'coverage_unavailable';\n return null;\n}\n\nfunction classifyScreening(response, context) {\n const failed = nodeFailure(response, context, 'screening');\n if (failed) return failed;\n const issue = resultIssue(response);\n if (issue) return failure(context, 'screening', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n return { ...context, route: 'fetch_evidence', result: response, screeningId: response.id };\n}\n\nfunction finishEvidence(evidence, context) {\n const failed = nodeFailure(evidence, context, 'evidence');\n if (failed) return failed;\n if (!isObject(evidence) || evidence.format !== 'sanctionskit-evidence@1') return failure(context, 'evidence', 'invalid_evidence');\n const issue = resultIssue(evidence.result);\n if (issue) return failure(context, 'evidence', issue, issue === 'coverage_unavailable' ? issue : 'request_error');\n // Preserve the entire document, but compare all original result fields to the\n // result that triggered this fetch. Object key order is immaterial.\n if (!Object.keys(context.result).every(k => canonical(context.result[k]) === canonical(evidence.result[k]))) return failure(context, 'evidence', 'evidence_result_mismatch');\n if (evidence.retention !== 'standard' || evidence.retainedInputs !== true || !Object.hasOwn(evidence, 'expires_at') || !Object.hasOwn(evidence, 'replayLimit') || evidence.reference !== context.eventId || canonical(evidence.subject) !== canonical(context.request.subject) || canonical(evidence.request) !== canonical(context.request)) return failure(context, 'evidence', 'evidence_input_mismatch');\n return {\n ...context,\n route: context.result.status === 'potential_match' ? 'review_required' : 'no_match_policy_review',\n stage: 'evidence_complete',\n evidence,\n onboardingDecision: 'not_made',\n };\n}\n\nfunction outputIndex(route) {\n return ({ review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3 })[route] ?? 2;\n}\n\n\nreturn $input.all().map((item, i) => ({ json: finishEvidence(item.json, $(\"Classify screening response\").itemMatching(i).json), pairedItem: { item: i } }));" + }, + "id": "build-review-record", + "name": "Build review record", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1540, + 100 + ] + }, + { + "parameters": { + "mode": "expression", + "numberOutputs": 4, + "output": "={{ ({review_required: 0, no_match_policy_review: 1, request_error: 2, coverage_unavailable: 3})[$json.route] ?? 2 }}" + }, + "id": "route-review-record", + "name": "Route review record", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 1770, + 250 + ] + }, + { + "parameters": {}, + "id": "potential-match-human-review", + "name": "Potential match - human review", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 0 + ] + }, + { + "parameters": {}, + "id": "no-match-apply-organization-policy", + "name": "No match - apply organization policy", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 170 + ] + }, + { + "parameters": {}, + "id": "request-or-evidence-error-incomplete", + "name": "Request or evidence error - incomplete", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 340 + ] + }, + { + "parameters": {}, + "id": "coverage-unavailable-incomplete", + "name": "Coverage unavailable - incomplete", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 2030, + 510 + ] + }, + { + "parameters": { + "content": "## Sandbox onboarding review\nUse n8n-nodes-sanctionskit@0.1.0 and select your own **sandbox** credential on both SanctionsKit nodes. Alex Morgan is invented. Coverage is fixed to sandbox@1. This template makes API requests only when you run it.\n\nPotential matches require human review. No match is limited to selected coverage and never approves onboarding. No durable queue, webhook, retry, or customer update is configured. Production requires deliberate adaptation; see README.", + "height": 300, + "width": 1040 + }, + "id": "read-first", + "name": "Read first", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + -170 + ] + } + ], + "pinData": {}, + "connections": { + "Manual demonstration": { + "main": [ + [ + { + "node": "Synthetic onboarding events", + "type": "main", + "index": 0 + } + ] + ] + }, + "Synthetic onboarding events": { + "main": [ + [ + { + "node": "Map onboarding event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Map onboarding event": { + "main": [ + [ + { + "node": "Screen synthetic subject", + "type": "main", + "index": 0 + } + ] + ] + }, + "Screen synthetic subject": { + "main": [ + [ + { + "node": "Classify screening response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify screening response": { + "main": [ + [ + { + "node": "Fetch evidence or incomplete", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch evidence or incomplete": { + "main": [ + [ + { + "node": "Get same screening evidence", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Route review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get same screening evidence": { + "main": [ + [ + { + "node": "Build review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build review record": { + "main": [ + [ + { + "node": "Route review record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route review record": { + "main": [ + [ + { + "node": "Potential match - human review", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No match - apply organization policy", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Request or evidence error - incomplete", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Coverage unavailable - incomplete", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "tags": [] +} diff --git a/integrations/n8n/package.json b/integrations/n8n/package.json new file mode 100644 index 0000000..ed6e592 --- /dev/null +++ b/integrations/n8n/package.json @@ -0,0 +1,11 @@ +{ + "name": "sanctionskit-n8n-onboarding-example", + "private": true, + "version": "1.0.0", + "description": "Synthetic onboarding review routing for n8n", + "scripts": { + "build": "node build.cjs", + "test": "node --test tests/*.test.cjs" + }, + "engines": { "node": ">=24" } +} diff --git a/integrations/n8n/tests/fixtures/base-evidence.json b/integrations/n8n/tests/fixtures/base-evidence.json new file mode 100644 index 0000000..de12c1f --- /dev/null +++ b/integrations/n8n/tests/fixtures/base-evidence.json @@ -0,0 +1,142 @@ +{ + "format": "sanctionskit-evidence@1", + "result": { + "id": "00000000-0000-4000-8000-000000000001", + "status": "potential_match", + "matches": [ + { + "score": 96, + "record": { + "id": "sandbox:person-001", + "list": "Synthetic demonstration records", + "names": [ + { + "kind": "primary", + "value": "Alex Morgan" + }, + { + "kind": "alias", + "value": "\u0410\u043b\u0435\u043a\u0441 \u041c\u043e\u0440\u0433\u0430\u043d" + } + ], + "sourceId": "sandbox-synthetic", + "addresses": [], + "authority": "SanctionsKit synthetic examples", + "sourceUrl": "https://sanctionskit.com/demo", + "birthDates": [ + { + "value": "1984", + "original": "1984", + "precision": "year" + } + ], + "entityType": "person", + "extensions": { + "synthetic": true + }, + "identifiers": [ + { + "type": "passport", + "value": "DEMO-001", + "issuer": "Fictional authority" + } + ], + "designations": [ + { + "wording": "Invented example, not a government designation.", + "category": "other", + "programs": [ + "Synthetic demonstration only" + ], + "legalReferences": [] + } + ], + "nationalities": [] + }, + "evidence": [ + { + "field": "name", + "method": "normalized_name_exact", + "queryValue": "Alex Morgan", + "explanation": "Name similarity is a review signal, not a probability or finding of wrongdoing.", + "sourceValue": "Alex Morgan", + "contribution": 96 + } + ], + "conflicts": [], + "assessment": { + "name": { + "method": "normalized_name_exact", + "aliasKind": "primary", + "similarity": 96, + "aliasQuality": "unknown" + }, + "country": { + "status": "not_supplied" + }, + "birthDate": { + "status": "not_supplied" + }, + "alertBasis": "name", + "entityType": { + "status": "agreement" + }, + "reviewTier": "name_only", + "identifiers": { + "status": "not_supplied", + "matched": 0, + "conflicts": 0, + "issuerUnknown": 0, + "issuerAgreements": 0 + }, + "hasConflicts": false, + "sameNameCandidates": 1 + } + } + ], + "coverage": [ + { + "fresh": true, + "version": "1", + "sourceId": "sandbox-synthetic", + "retrievedAt": "2026-09-17T12:00:00.000Z" + } + ], + "matching": { + "profile": "standard", + "version": "review-1.2.0", + "nameThreshold": 78, + "retrievalVersion": "indexed-1.1.1", + "corroboratedNameThreshold": 75 + }, + "versions": { + "policy": "review-1.2.0", + "dataset": "sandbox-synthetic-v1", + "package": "sandbox@1", + "matchingEngine": "deterministic-1.2.0" + }, + "createdAt": "2026-09-17T12:00:00.000Z", + "disclaimer": "Potential matches support analyst review. No match means no match in the selected sources and dataset, not legal clearance. Scores and evidence categories support review; they are not probabilities of identity or wrongdoing.", + "environment": "sandbox" + }, + "retention": "standard", + "expires_at": null, + "subject": { + "name": "Alex Morgan", + "entityType": "person", + "birthDate": "1984" + }, + "reference": "example-customer-001", + "request": { + "subject": { + "name": "Alex Morgan", + "entityType": "person", + "birthDate": "1984" + }, + "package": "sandbox@1", + "reference": "example-customer-001", + "retention": "standard" + }, + "retainedInputs": true, + "replayLimit": null +} diff --git a/integrations/n8n/tests/fixtures/cases.json b/integrations/n8n/tests/fixtures/cases.json new file mode 100644 index 0000000..75adb4c --- /dev/null +++ b/integrations/n8n/tests/fixtures/cases.json @@ -0,0 +1,12 @@ +[ + {"case":"potential_match","route":"review_required"}, + {"case":"coverage_unavailable","route":"coverage_unavailable"}, + {"case":"no_match","route":"no_match_policy_review"}, + {"case":"invalid_api_key","route":"request_error"}, + {"case":"stale_coverage","route":"coverage_unavailable"}, + {"case":"evidence_error","route":"request_error"}, + {"case":"idempotency_conflict","route":"request_error"}, + {"case":"evidence_wrong_id","route":"request_error"}, + {"case":"no_match_missing_coverage","route":"coverage_unavailable"}, + {"case":"unknown_error_shape","route":"request_error"} +] diff --git a/integrations/n8n/tests/fixtures/responses.cjs b/integrations/n8n/tests/fixtures/responses.cjs new file mode 100644 index 0000000..9dd76df --- /dev/null +++ b/integrations/n8n/tests/fixtures/responses.cjs @@ -0,0 +1,23 @@ +// Pure fixtures. build.cjs inlines these only into the explicitly OFFLINE workflow. +function fixtureScreening(context, baseEvidence) { + const name = context.fixtureCase; + if (['coverage_unavailable', 'stale_coverage', 'invalid_api_key', 'idempotency_conflict'].includes(name)) return { error: `SanctionsKit request failed (${name})` }; + if (name === 'unknown_error_shape') return { error: { code: 'coverage_unavailable' } }; + const result = JSON.parse(JSON.stringify(baseEvidence.result)); + if (name.startsWith('no_match')) { result.status = 'no_match'; result.matches = []; } + if (name === 'no_match_missing_coverage') result.coverage = []; + return result; +} + +function fixtureEvidence(context, baseEvidence) { + if (context.fixtureCase === 'evidence_error') return { error: 'SanctionsKit request failed (result_expired)' }; + const evidence = JSON.parse(JSON.stringify(baseEvidence)); + evidence.result = JSON.parse(JSON.stringify(context.result)); + evidence.subject = context.request.subject; + evidence.reference = context.eventId; + evidence.request = context.request; + if (context.fixtureCase === 'evidence_wrong_id') evidence.result.id = '00000000-0000-4000-8000-000000000099'; + return evidence; +} + +module.exports = { fixtureScreening, fixtureEvidence }; diff --git a/integrations/n8n/tests/native-runtime.cjs b/integrations/n8n/tests/native-runtime.cjs new file mode 100644 index 0000000..49c1b66 --- /dev/null +++ b/integrations/n8n/tests/native-runtime.cjs @@ -0,0 +1,116 @@ +// Optional native n8n check. Only the credential-free OFFLINE workflow is executed. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); + +const bin = process.env.N8N_BIN; +if (!bin || !path.isAbsolute(bin) || !fs.existsSync(bin)) throw new Error('Set N8N_BIN to the absolute path of an installed n8n/bin/n8n'); +const n8nPackage = JSON.parse(fs.readFileSync(path.join(path.dirname(bin), '..', 'package.json'))); +if (n8nPackage.version !== '2.41.4') throw new Error('This native check is qualified for n8n 2.41.4; review before using another runtime'); +const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'sanctionskit-n8n-onboarding-')); +const root = path.resolve(__dirname, '..'); +const commands = []; +const env = { + ...process.env, + N8N_DIAGNOSTICS_ENABLED: 'false', + N8N_VERSION_NOTIFICATIONS_ENABLED: 'false', + N8N_TEMPLATES_ENABLED: 'false', + N8N_LICENSE_AUTO_RENEW_ENABLED: 'false', + N8N_COMMUNITY_PACKAGES_ENABLED: 'false', + N8N_RUNNERS_MODE: 'internal', + N8N_RUNNERS_BROKER_LISTEN_ADDRESS: '127.0.0.1', + N8N_RUNNERS_BROKER_PORT: process.env.N8N_TEST_RUNNER_PORT || '16897', + // n8n's CLI also uses info-level logging for the --rawOutput JSON document. + N8N_LOG_LEVEL: 'info', +}; + +function run(state, label, args) { + const userFolder = path.join(directory, state); + fs.mkdirSync(userFolder, { recursive: true }); + const result = spawnSync(process.execPath, [bin, ...args], { env: { ...env, N8N_USER_FOLDER: userFolder }, encoding: 'utf8', timeout: 240000, maxBuffer: 30 * 1024 * 1024 }); + const output = (result.stdout || '') + (result.stderr || ''); + fs.writeFileSync(path.join(directory, label + '.log'), output); + commands.push({ label, args, status: result.status, signal: result.signal }); + if (result.error || result.status !== 0) throw new Error(`${label} failed; inspect ${directory}/${label}.log: ${result.error?.message || result.status}`); + return output; +} + +function onlyWorkflow(file) { + const value = JSON.parse(fs.readFileSync(file)); + assert.ok(Array.isArray(value) && value.length === 1, 'Expected one exported workflow'); + return value[0]; +} + +for (const mode of ['offline', 'sandbox']) { + const source = path.join(root, `onboarding-review.${mode}.json`); + const workflow = JSON.parse(fs.readFileSync(source)); + assert.equal(workflow.active, false); + assert.ok(workflow.nodes.every(n => !n.credentials)); + const exported = path.join(directory, mode + '-export.json'); + const roundtrip = path.join(directory, mode + '-roundtrip.json'); + run('first-state', mode + '-import', ['import:workflow', '--input=' + source]); + run('first-state', mode + '-export', ['export:workflow', '--id=' + workflow.id, '--output=' + exported]); + run('second-state', mode + '-roundtrip-import', ['import:workflow', '--input=' + exported]); + run('second-state', mode + '-roundtrip-export', ['export:workflow', '--id=' + workflow.id, '--output=' + roundtrip]); + for (const file of [exported, roundtrip]) { + const value = onlyWorkflow(file); + assert.deepEqual(value.nodes, workflow.nodes); + assert.deepEqual(value.connections, workflow.connections); + assert.deepEqual(value.pinData, {}); + assert.equal(value.active, false); + } +} + +const offline = require('../onboarding-review.offline.json'); +assert.ok(offline.nodes.every(n => ['manualTrigger', 'code', 'switch', 'noOp', 'stickyNote'].some(type => n.type === 'n8n-nodes-base.' + type))); +const raw = run('second-state', 'offline-execute', ['execute', '--id=' + offline.id, '--rawOutput']); +// CLI startup notices may precede the one JSON execution document. +const start = raw.indexOf('{\n "'); +assert.ok(start >= 0, 'Missing native execution JSON'); +let end = start, depth = 0, quoted = false, escaped = false; +for (; end < raw.length; end++) { + const char = raw[end]; + if (quoted) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') quoted = false; + } else if (char === '"') quoted = true; + else if (char === '{') depth++; + else if (char === '}' && --depth === 0) { end++; break; } +} +const execution = JSON.parse(raw.slice(start, end)); +assert.equal(execution.status, 'success'); +assert.equal(execution.data.resultData.error, undefined); +const runData = execution.data.resultData.runData; +const ends = ['Potential match - human review', 'No match - apply organization policy', 'Request or evidence error - incomplete', 'Coverage unavailable - incomplete']; +const records = ends.flatMap(name => (runData[name] || []).flatMap(run => run.data.main.flat().map(item => ({ ...item.json, actualEndpoint: name })))); +const expectedEndpoint = { review_required: ends[0], no_match_policy_review: ends[1], request_error: ends[2], coverage_unavailable: ends[3] }; +const cases = require('./fixtures/cases.json'); +assert.equal(records.length, cases.length); +assert.equal(new Set(records.map(record => record.eventId)).size, cases.length); +for (const record of records) { + assert.equal(record.route, cases.find(c => c.case === record.fixtureCase).route); + assert.equal(record.actualEndpoint, expectedEndpoint[record.route], 'Record reached the wrong native output node'); + assert.equal(record.onboardingDecision, 'not_made'); + assert.equal(record.requestKey, 'n8n:' + record.eventId); + if (record.evidence) assert.equal(record.evidence.reference, record.eventId); +} +const report = { + checkedAt: new Date().toISOString(), + nodeVersion: process.version, + n8nVersion: n8nPackage.version, + directory, + importsAndExports: 'both workflows imported/exported and re-imported/exported in separate fresh n8n state; node graph unchanged', + nativeExecuted: 'offline only', + sandboxExecuted: false, + hostedApiCalls: 0, + routes: Object.fromEntries(ends.map(name => [name, records.filter(record => record.actualEndpoint === name).length])), + cases: records.map(({ eventId, fixtureCase, route, actualEndpoint, onboardingDecision }) => ({ eventId, fixtureCase, route, actualEndpoint, onboardingDecision })), + sourceSha256: Object.fromEntries(['offline', 'sandbox'].map(mode => [mode, crypto.createHash('sha256').update(fs.readFileSync(path.join(root, `onboarding-review.${mode}.json`))).digest('hex')])), + commands, +}; +fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report, null, 2)); diff --git a/integrations/n8n/tests/review.test.cjs b/integrations/n8n/tests/review.test.cjs new file mode 100644 index 0000000..e9f0c4e --- /dev/null +++ b/integrations/n8n/tests/review.test.cjs @@ -0,0 +1,174 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { mapEvent, classifyScreening, finishEvidence, outputIndex } = require('../functions/review.cjs'); +const { fixtureScreening, fixtureEvidence } = require('./fixtures/responses.cjs'); +const base = require('./fixtures/base-evidence.json'); +const cases = require('./fixtures/cases.json'); +const event = { synthetic: true, eventId: 'onboarding:fixture:v1', subject: { name: 'Alex Morgan', entityType: 'person', birthDate: '1984' } }; +const clone = value => JSON.parse(JSON.stringify(value)); + +for (const scenario of cases) test('routes fixture: ' + scenario.case, () => { + const context = mapEvent({ ...event, fixtureCase: scenario.case }); + let output = classifyScreening(fixtureScreening(context, base), context); + if (output.route === 'fetch_evidence') output = finishEvidence(fixtureEvidence(output, base), output); + assert.equal(output.route, scenario.route); + assert.equal(output.eventId, event.eventId); + assert.equal(output.requestKey, 'n8n:' + event.eventId); + assert.equal(output.onboardingDecision, 'not_made'); + if (['review_required', 'no_match_policy_review'].includes(output.route)) { + assert.deepEqual(output.evidence.result, output.result); + assert.equal(output.evidence.reference, event.eventId); + assert.deepEqual(output.evidence.request, output.request); + } else assert.equal(output.evidence, undefined); +}); + +test('stable identity and unchanged request survive retries', () => { + assert.deepEqual(mapEvent(event), mapEvent(clone(event))); + assert.notEqual(mapEvent({ ...event, eventId: event.eventId + ':new' }).requestKey, mapEvent(event).requestKey); + assert.equal(mapEvent({ ...event, subject: { name: 'Alex Morgan', entityType: 'person' } }).request.subject.birthDate, undefined); +}); + +test('invalid or unmarked input stops before screening', () => { + for (const bad of [{ ...event, synthetic: false }, { ...event, eventId: 'has spaces' }, { ...event, eventId: 12345678 }, { ...event, eventId: 'x'.repeat(125) }, { ...event, subject: { ...event.subject, birthDate: '0000' } }, { ...event, subject: { ...event.subject, birthDate: 1984 } }]) assert.throws(() => mapEvent(bad)); +}); + +test('malformed results, wrong environment, and ambiguous errors stay incomplete', () => { + const context = mapEvent(event); + for (const bad of [null, {}, { ...base.result, environment: 'production' }, { ...base.result, status: 'cleared' }, { ...base.result, status: 'no_match' }, { ...base.result, matches: [] }, { ...base.result, versions: {} }, { ...base.result, disclaimer: '' }, { error: '' }, { error: 'prefix SanctionsKit request failed (coverage_unavailable)' }]) assert.equal(classifyScreening(bad, context).route, 'request_error'); + assert.equal(classifyScreening({ ...base.result, coverage: [{ ...base.result.coverage[0], fresh: false }] }, context).route, 'coverage_unavailable'); + assert.equal(outputIndex('unrecognized'), 2); +}); + +test('evidence must belong to the same result and input, with retained standard inputs', () => { + const context = classifyScreening(clone(base.result), mapEvent(event)); + const valid = fixtureEvidence(context, base); + const variants = [ + { ...valid, format: 'unknown' }, { ...valid, retention: 'minimal' }, { ...valid, retainedInputs: false }, + { ...valid, reference: 'other-event' }, { ...valid, subject: { name: 'Other synthetic subject' } }, + { ...valid, request: { ...valid.request, reference: 'other-event' } }, + { ...valid, request: { ...valid.request, policy: { id: '00000000-0000-4000-8000-000000000002', version: 1 } } }, + { ...valid, result: { ...valid.result, disclaimer: 'changed' } }, + { ...valid, result: { ...valid.result, versions: { ...valid.result.versions, dataset: 'other' } } }, + ]; + for (const bad of variants) { + const output = finishEvidence(bad, context); + assert.equal(output.route, 'request_error'); + assert.deepEqual(output.result, context.result); + assert.equal(output.onboardingDecision, 'not_made'); + } + const reordered = clone(valid); + reordered.result = Object.fromEntries(Object.entries(reordered.result).reverse()); + assert.equal(finishEvidence(reordered, context).route, 'review_required'); +}); + +test('malformed candidates and an unexpected package remain incomplete', () => { + const context = mapEvent(event); + for (const bad of [null, {}, { ...base.result.matches[0], evidence: [null] }, { ...base.result.matches[0], record: {} }, { ...base.result.matches[0], score: 101 }]) { + assert.equal(classifyScreening({ ...base.result, matches: [bad] }, context).route, 'request_error'); + } + assert.equal(classifyScreening({ ...base.result, versions: { ...base.result.versions, package: 'other@1' } }, context).route, 'request_error'); +}); + +test('exports contain no credentials or pin data and sandbox cannot silently use production', () => { + for (const mode of ['offline', 'sandbox']) { + const flow = require('../onboarding-review.' + mode + '.json'); + assert.equal(flow.active, false); + assert.deepEqual(flow.pinData, {}); + for (const node of flow.nodes) assert.equal(node.credentials, undefined); + const api = flow.nodes.filter(n => n.type === 'n8n-nodes-sanctionskit.sanctionsKit'); + assert.equal(api.length, mode === 'offline' ? 0 : 2); + for (const node of api) assert.equal(node.onError, 'continueRegularOutput'); + if (mode === 'sandbox') { + assert.equal(api[0].parameters.coverage, 'sandbox'); + assert.equal(api[0].parameters.simplify, false); + assert.equal(api[1].parameters.screeningId, '={{ $json.screeningId }}'); + } + } +}); + +test('exported Code nodes preserve item links when evidence inputs are filtered', () => { + const flow = require('../onboarding-review.offline.json'); + const run = (name, items, upstream = {}) => { + const node = flow.nodes.find(n => n.name === name); + return vm.runInNewContext('(function(){' + node.parameters.jsCode + '\n})()', { + $input: { all: () => items }, + $: source => ({ itemMatching: i => upstream[source][i] }), + }); + }; + const events = run('Synthetic onboarding events', []); + const mapped = run('Map onboarding event', events); + const screened = run('Screen synthetic subject', mapped); + const classified = run('Classify screening response', screened, { 'Map onboarding event': mapped }); + const fetchedInputs = classified.filter(item => item.json.route === 'fetch_evidence'); + const evidence = run('Get same screening evidence', fetchedInputs); + const finished = run('Build review record', evidence, { 'Classify screening response': fetchedInputs }); + const all = [...finished, ...classified.filter(item => item.json.route !== 'fetch_evidence')]; + assert.equal(all.length, cases.length); + assert.equal(new Set(all.map(item => item.json.eventId)).size, cases.length); + for (const item of all) assert.equal(item.json.route, cases.find(c => c.case === item.json.fixtureCase).route); + finished.forEach((item, i) => assert.equal(item.pairedItem.item, i)); +}); + +test('published node v0.1.0 contract using intercepted helper only', { skip: !process.env.SANCTIONSKIT_NODE_PACKAGE }, async () => { + const packageRoot = path.resolve(process.env.SANCTIONSKIT_NODE_PACKAGE); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'))).version, '0.1.0'); + const { SanctionsKit } = require(path.join(packageRoot, 'dist/nodes/SanctionsKit/SanctionsKit.node.js')); + const workflow = require('../onboarding-review.sandbox.json'); + const contexts = cases.map(c => mapEvent({ ...event, eventId: 'onboarding:fixture:' + c.case + ':v1', fixtureCase: c.case })); + const calls = []; + async function execute(operation, inputs) { + let current; + const node = workflow.nodes.find(n => n.type === 'n8n-nodes-sanctionskit.sanctionsKit' && n.parameters.operation === operation); + const resolve = value => { + if (typeof value === 'string' && value.startsWith('={{') && value.endsWith('}}')) return vm.runInNewContext(value.slice(3, -2), { $json: current }); + if (Array.isArray(value)) return value.map(resolve); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolve(v)])); + return value; + }; + return (await new SanctionsKit().execute.call({ + getInputData: () => inputs.map(json => ({ json })), + getNode: () => node, + getCredentials: async () => ({ apiKey: 'sk_test_fixture_not_a_real_key', environment: 'sandbox' }), + continueOnFail: () => true, + getNodeParameter: (name, i, fallback) => { + current = inputs[i]; + return node.parameters[name] === undefined ? fallback : resolve(node.parameters[name]); + }, + helpers: { httpRequestWithAuthentication: async (credentialType, request) => { + calls.push({ context: current, operation, credentialType, request: clone(request) }); + const response = operation === 'create' ? fixtureScreening(current, base) : fixtureEvidence(current, base); + if (typeof response.error === 'string') { + const code = /\(([a-z_]+)\)/.exec(response.error)[1]; + throw { statusCode: ['coverage_unavailable', 'stale_coverage'].includes(code) ? 503 : 400, response: { data: { error: { code } } } }; + } + return operation === 'create' ? { data: response } : response; + } }, + }))[0]; + } + const screened = await execute('create', contexts); + assert.equal(screened.length, contexts.length); + const classified = screened.map((item, i) => { + assert.equal(item.pairedItem.item, i); + return classifyScreening(item.json, contexts[i]); + }); + const retained = classified.filter(c => c.route === 'fetch_evidence'); + const evidence = await execute('getEvidence', retained); + const finished = evidence.map((item, i) => { + assert.equal(item.pairedItem.item, i); + return finishEvidence(item.json, retained[i]); + }); + const all = [...finished, ...classified.filter(c => c.route !== 'fetch_evidence')]; + assert.equal(all.length, cases.length); + for (const item of all) assert.equal(item.route, cases.find(c => c.case === item.fixtureCase).route); + for (const call of calls) { + assert.equal(call.credentialType, 'sanctionsKitApi'); + if (call.operation === 'create') { + assert.equal(call.request.headers['Idempotency-Key'], call.context.requestKey); + assert.deepEqual(call.request.body, call.context.request); + assert.equal(call.request.url, 'https://www.sanctionskit.com/api/v1/screenings'); + } else assert.equal(call.request.url, 'https://www.sanctionskit.com/api/v1/results/' + call.context.screeningId + '/evidence'); + } +});