Appearance
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
| App | Capability | Vendor | Actions | Hosts | Secrets |
|---|---|---|---|---|---|
mock-sanctions | sanctions.screen | none | screen, screenAsync | none | none |
doc-verify-mock | document.verify | none | verify (asynchronous) | none | none |
opensanctions | sanctions.screen | OpenSanctions | screen, screenBatch | api.opensanctions.org, *.opensanctions.org | apiKey |
sumsub | document.verify | Sumsub | verify (asynchronous) | api.sumsub.com, *.sumsub.com | appToken, 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.
| Config | Default | Meaning |
|---|---|---|
blocklist | [] | Names that count as a hit. |
caseInsensitive | true | Compare names regardless of case. |
webhookSecret | mock-webhook-secret | Shared 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:invokecomputes 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_webhooktakes{ id, externalId }signed withx-mock-signature(hex HMAC-SHA256 of the raw body underwebhookSecret), 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 declareswebhookExternalId: { 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.
| Config | Default | Meaning |
|---|---|---|
webhookSecret | mock-idv-webhook-secret | Shared secret the mock vendor's webhooks are signed with. |
nameMatchThreshold | 0.8 | Minimum 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
blankreads as nothing:review, every checknull. - The file name's words (split on dashes, underscores, dots and spaces) set the verdict:
expiredgives an expiry date a year back (otherwise five years ahead),invalida bad machine-readable zone,faila vendor failure (the webhook then fails the call). Words that describe the file (passport,id,scan,front,validand 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. nameMatchcompares the name on the document with the applicant's (fullName, elsefirstNameandlastName); it isnullwhen either is missing. Any failed check isdeclined; otherwiseapprovedwhen a check ran,reviewwhen 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.
| Config | Default | Meaning |
|---|---|---|
baseUrl | https://api.opensanctions.org | The API origin; its host must be one of the app's hosts (api.opensanctions.org, *.opensanctions.org). |
dataset | default | Dataset to match against (default, sanctions, peps, ...). |
algorithm | logic-v2 | Scoring algorithm; best lets the server pick. |
threshold | 0.7 | Score at or above which a candidate counts as a hit (also sent to the API). |
topics | unset | Restrict candidates to entities with any of these topics, e.g. ["sanction"] or ["role.pep"]. |
limit | 5 | Candidates 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 queryq0withschema: typeand list-valued properties (name,country,birthDatefor persons,registrationNumberfor companies); the URL carriesalgorithm,threshold,limitand onetopics=parameter per topic. Output{ hit, matches, total, query }:matchesare the candidates withmatch: trueorscore >= threshold, best first, each{ id, caption, schema, score, match, datasets, topics, explanations }(topicsfrom the entity's properties,explanationspassed through);totalis the API's candidate count;queryechoes the dataset, algorithm, threshold and topics used.screenBatch(30 s, the same retry):{ queries }(1 to 100) in one request with idsq0..qN;resultskeep the input order.topicsis 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.
| Config | Default | Meaning |
|---|---|---|
baseUrl | https://api.sumsub.com | The API origin, on one of the app's hosts (api.sumsub.com, *.sumsub.com). |
levelName | id-only | Verification level the applicant is created on and WebSDK tokens are issued for. |
idDocType | PASSPORT | Sumsub idDocType sent with the upload (PASSPORT, ID_CARD, DRIVERS, ...). |
country | USA | ISO 3166-1 alpha-3 country of issue sent with the upload. |
uploadDocument | true | Upload the document from the input; false creates the applicant without one and the WebSDK collects it. |
sdkTokenTtlSeconds | 600 | How 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:
POST /resources/applicants?levelName=<levelName>with{ externalUserId, fixedInfo: { firstName?, lastName?, dob? } }.externalUserIddefaults toaletheia:<tenantId>:<documentId>. On 409 (the applicant exists, for instance on a retry) the app reads it withGET /resources/applicants/-;externalUserId=<id>/one.- When
uploadDocumentis true,POST /resources/applicants/{id}/info/idDocwith a multipart body:metadata({ idDocType, country }) andcontent, the document streamed in by the host. 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, plusapplicantPendingand the other status events to have them recorded as pending; - the digest algorithm
HMAC_SHA256_HEX; the app also acceptsHMAC_SHA1_HEXandHMAC_SHA512_HEX, and reads a missingX-Payload-Digest-Algheader 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.
- Build. Each
catalog/<name>package (@aletheia-dev/app-<name>, private) builds withscripts/catalog-build.mjs: the SDK's build with the pinned tools thatpnpm tools:extisminstalls under.tools/(extism-js v1.7.0 and binaryen version_133, checksummed), intodist/app.wasmanddist/app.json. A package's tests run its built module, so itstesttask depends on its ownbuild. - Index.
pnpm catalog:indexcopies every package's module tocatalog/dist/<name>.wasmand writescatalog/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. - Image. The Dockerfile's
compilestage runs both (it uses a newer Debian than the runtime images, because extism-js needs glibc 2.39), and the API image carriescatalog/distat/app/catalogwithAPP_CATALOG_DIR=/app/catalog(Deployment). - 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.