Skip to content

App catalogue ​

The platform apps every release ships, how a tenant installs one, what each declares (its configuration, secrets, hosts and actions), and how the catalogue is built and published with a release. Writing an app of your own is covered in Apps: authoring; the contract in Apps: reference.

Aletheia is infrastructure: the catalogue holds a deterministic mock and one reference vendor for each capability, to exercise the platform and prove the SDK. It is not a vendor catalogue; company-registry lookups and other data integrations are apps a customer builds and uploads as its own.

The apps ​

AppCapabilityVendorActionsHostsSecrets
mock-sanctionssanctions.screennonescreen, screenAsyncnonenone
doc-verify-mockdocument.verifynoneverify (asynchronous)nonenone
opensanctionssanctions.screenOpenSanctionsscreen, screenBatchapi.opensanctions.org, *.opensanctions.orgapiKey
sumsubdocument.verifySumsubverify (asynchronous)api.sumsub.com, *.sumsub.comappToken, secretKey, webhookSecret

Each is a package under catalog/<name> built with @aletheia-dev/app-sdk. The screeners both answer hit and a matches list, and the two document verifiers answer the same output shape, so a rule or a step written against a mock keeps working when it names the real vendor instead. The webhooks of a platform app arrive on <WEBHOOK_PUBLIC_URL>/webhooks/apps/<name>.

Installing a platform app ​

Connect › Apps lists the catalogue with each app's state for the organization; an app's page installs it: the version, the hosts it calls (approved here), the configuration form generated from its schema, its secrets and Enabled for this organization (Admin console). Through the API, the sequence is the one in Apps: authoring without the upload: install disabled with PUT /apps/:name/install, store each secret with POST /apps/:name/secrets/:secret, then enable. GET /apps names the published version to install (latest.id).

In development, pnpm db:seed records the catalogue (from APP_CATALOG_DIR) and installs mock-sanctions (with blocklist: ["Evil Corp"]) and doc-verify-mock for the development tenant, enabled; opensanctions and sumsub stay uninstalled until someone stores their secrets. SEED_SANCTIONS_APP=opensanctions pnpm db:seed points the seeded sanctions_hit and pep_hit rules and the kyb-onboarding workflow's screen step at opensanctions instead, and installs it disabled until its apiKey is stored and the install enabled.

A release that carries a newer version of an installed app publishes it beside the old one: the install keeps running its version and the catalogue marks it updateAvailable until an administrator moves it (the app's page, or the same PUT with the new versionId).

mock-sanctions ​

Screens a name against a blocklist the install configures. No vendor, no hosts and no secrets, so it exercises the whole platform in tests, the smoke and the example policies.

ConfigDefaultMeaning
blocklist[]Names that count as a hit.
caseInsensitivetrueCompare names regardless of case.
webhookSecretmock-webhook-secretShared secret the mock vendor's webhooks are signed with.
  • screen (synchronous, 5 s, idempotent, retried once after 100 ms): input { name, country? }, output { hit, matches: [{ name, score: 1 }] }, an exact match of the name against the blocklist.
  • screenAsync (5 s, idempotent, callback within 300 s): the same screening, delivered through a webhook. A module has no timers and this one no hosts, so it cannot call itself back: invoke computes the verdict at once and returns it inside the external id (<uuid>.<base64url JSON>, under 200 characters), and whoever plays the vendor posts the webhook that names the id. handle_webhook takes { id, externalId } signed with x-mock-signature (hex HMAC-SHA256 of the raw body under webhookSecret), reads the verdict back from the id and completes the call; an unsigned or badly signed webhook, or an id the mock did not issue, is rejected. The manifest declares webhookExternalId: { json: '/externalId' }, so the URL needs no query string.

In development, pnpm mock:callback plays the vendor (the smoke scripts post the same webhook themselves). The pending session's id is in the run context (<outputKey>ExternalId) and in GET /app-callbacks:

bash
EXTERNAL_ID=$(curl -s "$API/app-callbacks?workflowRunId=$RUN&status=pending" \
  -H "authorization: Bearer $ADMIN_PAT" | jq -r '.items[0].externalId')
pnpm mock:callback http://localhost:4000/webhooks/apps/mock-sanctions "$EXTERNAL_ID"

--secret and --header override the signature's secret and header, --event-id sets the event id (posting the same one twice is a duplicate to the API).

doc-verify-mock ​

Verifies an uploaded identity document without a vendor and without OCR: it reads the document through the host (needs: ['documents']) and decides from its header and its file name, so a developer chooses the verdict by naming the file.

ConfigDefaultMeaning
webhookSecretmock-idv-webhook-secretShared secret the mock vendor's webhooks are signed with.
nameMatchThreshold0.8Minimum name similarity (Dice coefficient on bigrams) counted a match.

verify (10 s, idempotent, callback within 600 s) takes { documentId, applicant?: { fullName?, firstName?, lastName?, dateOfBirth? } } and answers

json
{
  "outcome": "approved | declined | review",
  "checks": { "nameMatch": true, "expired": false, "mrzValid": true },
  "extracted": {
    "fullName": "Jane Doe",
    "documentNumber": "0B1A2C3D4",
    "expiryDate": "2031-10-07"
  },
  "engine": "mock"
}

How it reads a document:

  • A file that is not a PNG, JPEG or PDF by its first bytes, is shorter than 64 bytes, or whose name contains the word blank reads as nothing: review, every check null.
  • The file name's words (split on dashes, underscores, dots and spaces) set the verdict: expired gives an expiry date a year back (otherwise five years ahead), invalid a bad machine-readable zone, fail a vendor failure (the webhook then fails the call). Words that describe the file (passport, id, scan, front, valid and the like) are ignored; any other words are the name on the document, which is the applicant's own when the file name has none.
  • nameMatch compares the name on the document with the applicant's (fullName, else firstName and lastName); it is null when either is missing. Any failed check is declined; otherwise approved when a check ran, review when none could.

So passport-valid.png is the applicant's valid passport, passport-expired.png their expired one and id-john-smith.jpg somebody else's. Like mock-sanctions, verify answers pending with the verdict inside the external id, and a signed webhook completes it: { id, externalId } with x-mock-idv-signature, which pnpm mock:callback <callback-url> <external-id> --idv posts. The seeded kyb-onboarding workflow calls it from its idv step (Documents).

OpenSanctions ​

Sanctions and PEP screening on the OpenSanctions matching API, POST {baseUrl}/match/{dataset}, which a self-hosted yente exposes identically. The app ships no data.

ConfigDefaultMeaning
baseUrlhttps://api.opensanctions.orgThe API origin; its host must be one of the app's hosts (api.opensanctions.org, *.opensanctions.org).
datasetdefaultDataset to match against (default, sanctions, peps, ...).
algorithmlogic-v2Scoring algorithm; best lets the server pick.
threshold0.7Score at or above which a candidate counts as a hit (also sent to the API).
topicsunsetRestrict candidates to entities with any of these topics, e.g. ["sanction"] or ["role.pep"].
limit5Candidates returned per query (1 to 50).

Secret: apiKey, sent as Authorization: ApiKey <key> with every request; the host puts it in, and it is never logged. Get a trial key at https://www.opensanctions.org/api/ (the hosted API is metered per request). A self-hosted yente outside opensanctions.org is not one of the app's hosts, so reaching one takes a tenant app built from catalog/opensanctions that declares its host.

Licence: OpenSanctions data is published under CC BY-NC 4.0. Non-commercial use is free with attribution; any commercial use, including running Aletheia for paying customers, needs an OpenSanctions commercial licence or API subscription. The app only calls the API; the licence obligations sit with the tenant that holds the key.

Actions:

  • screen (synchronous, 15 s, idempotent, retried once after 500 ms): input { name, type: 'Person' | 'Company' (default Company), country?, birthDate?, registrationNumber?, topics? }. The request body is one query q0 with schema: type and list-valued properties (name, country, birthDate for persons, registrationNumber for companies); the URL carries algorithm, threshold, limit and one topics= parameter per topic. Output { hit, matches, total, query }: matches are the candidates with match: true or score >= threshold, best first, each { id, caption, schema, score, match, datasets, topics, explanations } (topics from the entity's properties, explanations passed through); total is the API's candidate count; query echoes the dataset, algorithm, threshold and topics used.
  • screenBatch (30 s, the same retry): { queries } (1 to 100) in one request with ids q0..qN; results keep the input order. topics is a request-level parameter, so every query in a batch must resolve to the same list.

Topics and PEPs: the API filters on topics before scoring, so a sanctions-only rule sets topics: ["sanction"] and a PEP rule topics: ["role.pep"]. An input topics overrides the configured list for that call, which is how one install serves both kinds of rule: the seeded pep_hit rule passes { "type": "Person", "topics": ["role.pep"] } as literal input (Rules). Without a filter, hits carry whatever topics the entity has (sanction, role.pep, crime, ...), so a rule can also decide on matches[].topics.

Errors: a non-2xx answer fails the call with the status and the first 200 characters of the body (401 and 403 point at the API key); 429 and 5xx are marked retryable, others are not. An answer that is not the expected shape fails the call before anything is read from it.

Sumsub ​

Document verification with Sumsub. verify is asynchronous: it creates an applicant with the document, and the verdict arrives later through Sumsub's webhook, mapped to the same output shape as doc-verify-mock. While the applicant still has steps to take at Sumsub (a liveness check, documents the WebSDK collects), the app hands the session to the collection terminal, which shows Sumsub's WebSDK after the submit. Sandbox and production share the host https://api.sumsub.com; the app token decides the environment.

Sandbox credentials: in the Sumsub dashboard switch to Sandbox mode, open Dev space > App tokens and create a token pair: the app token (sbx:...) and the secret key. Create a verification level with an identity-document step, or use id-only (the default this app expects), a preset of new sandboxes alongside id-and-liveness.

ConfigDefaultMeaning
baseUrlhttps://api.sumsub.comThe API origin, on one of the app's hosts (api.sumsub.com, *.sumsub.com).
levelNameid-onlyVerification level the applicant is created on and WebSDK tokens are issued for.
idDocTypePASSPORTSumsub idDocType sent with the upload (PASSPORT, ID_CARD, DRIVERS, ...).
countryUSAISO 3166-1 alpha-3 country of issue sent with the upload.
uploadDocumenttrueUpload the document from the input; false creates the applicant without one and the WebSDK collects it.
sdkTokenTtlSeconds600How long a WebSDK token handed to the applicant's browser lives; the terminal renews it.

Secrets: appToken (sent as X-App-Token), secretKey (signs every request) and webhookSecret (the secret of the dashboard webhook). Every request carries X-App-Access-Ts and X-App-Access-Sig, the hex HMAC-SHA256 under the secret key of ts + METHOD + path-with-query + body. The host computes the signature over the bytes it sends (the multipart upload included) with its sign option, so neither the secret key nor the document enters the module. None of the secrets is ever logged, and neither is a WebSDK token.

Action verify (60 s per attempt, idempotent, retried once after 1 s, callback within 24 hours): input { documentId, applicant?: { firstName?, lastName?, dateOfBirth? }, externalUserId? }. The request sequence is:

  1. POST /resources/applicants?levelName=<levelName> with { externalUserId, fixedInfo: { firstName?, lastName?, dob? } }. externalUserId defaults to aletheia:<tenantId>:<documentId>. On 409 (the applicant exists, for instance on a retry) the app reads it with GET /resources/applicants/-;externalUserId=<id>/one.
  2. When uploadDocument is true, POST /resources/applicants/{id}/info/idDoc with a multipart body: metadata ({ idDocType, country }) and content, the document streamed in by the host.
  3. pending(externalUserId).

Output: { outcome: 'approved' | 'declined' | 'review', checks, extracted, engine: 'sumsub', vendor }. Sumsub reports a verdict, not individual checks, so every checks value is null and extracted is empty; vendor carries { applicantId, reviewAnswer, rejectLabels, reviewRejectType?, levelName? }. GREEN is approved, RED with FINAL is declined and RED with RETRY (or no reject type) is review.

Hand-off: the manifest declares handoff: 'sumsub'. Asked for a pending session, the app reads the applicant (GET /resources/applicants/-;externalUserId=<id>/one) and, while its review.reviewStatus is init (documents or a liveness check missing) or awaitingUser (the review asks for more), answers a WebSDK token from POST /resources/accessTokens/sdk ({ userId: externalUserId, levelName, ttlInSecs }); otherwise null, and the terminal shows the check's progress. For the WebSDK to load, set COLLECTION_TERMINAL_HANDOFF_SDKS=sumsub on the web image (Deployment) and add the terminal's host (COLLECTION_FLOW_URL) to the allowed domains of the WebSDK in the dashboard (Integrations > WebSDK settings). See Embedding for what applicants see.

Webhook setup: Sumsub has one webhook URL per account, not per session, so the manifest declares webhookExternalId: { json: '/externalUserId' }: the API finds the session from the body's externalUserId, then the app checks the signature with the install's secret. In the dashboard, Dev space > Webhooks, create a webhook with:

  • the URL <WEBHOOK_PUBLIC_URL>/webhooks/apps/sumsub (no query string);
  • the type applicantReviewed, plus applicantPending and the other status events to have them recorded as pending;
  • the digest algorithm HMAC_SHA256_HEX; the app also accepts HMAC_SHA1_HEX and HMAC_SHA512_HEX, and reads a missing X-Payload-Digest-Alg header as SHA-1;
  • a secret key, which you store as the install's webhookSecret.

The app rejects a webhook without X-Payload-Digest, or whose digest does not match the raw body (401). applicantReviewed with reviewStatus: 'completed' completes the call (the event id is applicantReviewed:<correlationId>, else built from the applicant id and the timestamp); an applicantReviewed without a completed review, and applicantCreated, applicantPending, applicantPrechecked, applicantOnHold, applicantAwaitingService and applicantAwaitingUser, are recorded as pending; every other type is ignored. In the sandbox a review can be forced with POST /resources/applicants/{id}/status/testCompleted{ reviewAnswer: 'GREEN' | 'RED', rejectLabels, reviewRejectType? }, which triggers the webhook like a real review.

Errors: a non-2xx answer fails the call with the status and the first 200 characters of the body (401 and 403 point at the credentials and the sandbox); 429 and 5xx are marked retryable.

How the catalogue is built and published ​

The catalogue is part of the release: its apps are built from the repository with the release's SDK and carried in the API image, and the API publishes them when it starts.

  1. Build. Each catalog/<name> package (@aletheia-dev/app-<name>, private) builds with scripts/catalog-build.mjs: the SDK's build with the pinned tools that pnpm tools:extism installs under .tools/ (extism-js v1.7.0 and binaryen version_133, checksummed), into dist/app.wasm and dist/app.json. A package's tests run its built module, so its test task depends on its own build.
  2. Index. pnpm catalog:index copies every package's module to catalog/dist/<name>.wasm and writes catalog/dist/index.json: per app its name, version, SHA-256, size, manifest and file name. It fails, naming the package, when one has no build: the catalogue is complete or not at all.
  3. Image. The Dockerfile's compile stage runs both (it uses a newer Debian than the runtime images, because extism-js needs glibc 2.39), and the API image carries catalog/dist at /app/catalog with APP_CATALOG_DIR=/app/catalog (Deployment).
  4. Publish. At start-up, before it listens, the API reconciles the index with the platform versions it has: a version is recorded for each entry it does not have yet, the module is uploaded to object storage when storage has no object for its hash (after checking that its memory maximum is the one its manifest names), a withdrawn version the index carries again is published again, and a published platform version the index no longer carries is withdrawn (installs keep running it). A build is not byte-reproducible (extism-js snapshots the runtime with its random seeds), so an image built again carries the same app version with another module: when its manifest is the published one, the published module stays and the new build is unused. An entry whose name and version are already published with another manifest stops the start-up: a changed app needs a new version number. Without object storage or an app runner the API skips the catalogue and runs without apps.

In development the same index comes from pnpm tools:extism, pnpm turbo run build --filter='./catalog/*' and pnpm catalog:index, with APP_CATALOG_DIR pointing at catalog/dist (Get started). Adding an app to the catalogue is a pull request: pnpm app:new <name> scaffolds catalog/<name>, and the hosts it declares are reviewed with it.

Released under the Apache-2.0 License.