Skip to content
Comms.ID
Esc
↑↓navigate↵open⌘Jpreview
On this page

Screen a party against the sanctions list, and review the matches

Screen a party against the sanctions list, and review the matches

What you build: at onboarding or before a payment, your server screens the party against the Australian Sanctions Consolidated List (DFAT). No possible match is recorded with the list generation it was checked against; possible matches go to an analyst, who sees every candidate’s evidence on a review page; an unavailable list holds the case. A screening is never an automatic decision, and an outage is never a clearance.

Before you start: a development app, then a production app

  1. npx comms-id init makes a development key in .env.local and prints its public key. In the Companion App, register it as a development app, enable Sanctions for it, and put the app id in .env.local as COMMS_ID_CLIENT_ID. This key reaches only the TEST service (https://api-test.comms.id): the same API, errors and authentication as LIVE, with synthetic replies.
  2. When it works in TEST, npx comms-id key --worker <your worker> makes the production key straight into your server’s COMMS_ID_PRIVATE_JWK secret and prints the public key; register it as a production app with Sanctions enabled. Going live changes only the environment and the key: nothing in the code below.

A .env.local never holds a production key, and a browser never holds any key.

Sanctions screening is server and relay only: there is no public visitor mode, and the review page reaches the service through your relay.

The server side

pnpm add @comms-id/sanctions

screen.ts:

// Screen one person, entity or vessel against the DFAT Consolidated List on your server, at
// onboarding or before a payment. The answer is a list of possible matches for an analyst to
// review, never an automatic decision; an unavailable list is reported as such, never as "clear".
import { outcomeOf, type SanctionsClient, type ScreenRequest, type ScreenResponse } from "@comms-id/sanctions";

export type Screening =
  | { readonly outcome: "no-possible-match"; readonly generation: string; readonly checkedAt: string }
  | { readonly outcome: "review"; readonly reply: ScreenResponse }
  | { readonly outcome: "unavailable"; readonly message: string };

export const screenParty = async (party: ScreenRequest, sanctions: SanctionsClient): Promise<Screening> => {
  try {
    const reply = await sanctions.screen(party);
    if (reply.candidates.length === 0) {
      // A statement about one generation of the list, not a clearance: keep the generation with the record.
      return { outcome: "no-possible-match", generation: reply.generation, checkedAt: reply.checkedAt };
    }
    return { outcome: "review", reply };
  } catch (error) {
    if (outcomeOf(error) === "source-unavailable") {
      return { outcome: "unavailable", message: "The sanctions list is unavailable; hold the case and try again." };
    }
    throw error;
  }
};

TEST first

import { createSanctionsClient } from "@comms-id/sanctions/server";
import { screenParty } from "./screen";

const sanctions = createSanctionsClient({
  clientId: process.env.COMMS_ID_CLIENT_ID!,
  privateJwk: JSON.parse(process.env.COMMS_ID_PRIVATE_JWK!),
  environment: "test",
});

await screenParty({ type: "Individual", name: "Example Person" }, sanctions); // review: candidates with evidence
await screenParty({ type: "Individual", name: "Example No Match" }, sanctions); // no-possible-match, with the generation
await screenParty({ type: "Individual", name: "Example Unavailable" }, sanctions); // unavailable: hold the case

TEST screens only its six sample names (Example Person, Example Entity, Example Vessel, Example No Match, Example Unavailable, Example RateLimit); any other name is 400 INVALID_INPUT.

The review page

pnpm add @comms-id/sanctions-react react

ReviewScreen.tsx re-runs the screening in the browser through your relay with the party’s details filled in, so the analyst sees the candidates, their aliases, dates and places of birth, and which of them matched:

// The analyst's review page: the same screening, re-run in the browser through your relay
// with the party's details filled in, so the analyst sees the evidence for every candidate.
import type { SanctionsClient, ScreenRequest } from "@comms-id/sanctions";
import { createSanctionsBrowserClient } from "@comms-id/sanctions/browser";
import { SanctionsScreen } from "@comms-id/sanctions-react";

export const relayClient = (): SanctionsClient => createSanctionsBrowserClient({ relayUrl: "/api/comms-id/sanctions" });

export function ReviewScreen({
  party,
  client = relayClient(),
}: {
  readonly party: ScreenRequest;
  readonly client?: SanctionsClient;
}) {
  return (
    <section>
      <h1>Sanctions review</h1>
      <SanctionsScreen
        client={client}
        legend={`Screening ${party.name}`}
        onResult={(record) =>
          console.log(`${record.candidates.length} possible matches on generation ${record.generation}`)
        }
        values={{
          name: party.name,
          type: party.type,
          dateOfBirth: party.dateOfBirth ?? "",
          placeOfBirth: party.placeOfBirth ?? "",
        }}
      />
    </section>
  );
}

Your relay: one route, one signer

The browser calls your own server at /api/comms-id/<product>/<operation>; your server signs each call as your registered app with @comms-id/relay, so the page holds no credential. The relay forwards only the operations the product descriptors list, and never the user’s cookies.

pnpm add @comms-id/relay @comms-id/address @comms-id/asx @comms-id/charity @comms-id/logo @comms-id/sanctions

app/api/comms-id/[product]/[operation]/route.ts (Next.js; @comms-id/relay/hono is the same for Hono):

// app/api/comms-id/[product]/[operation]/route.ts (Next.js). One route serves every product
// your pages use; the relay forwards only the operations these descriptors list.
import { addressProduct } from "@comms-id/address/descriptor";
import { asxProduct } from "@comms-id/asx/descriptor";
import { charityProduct } from "@comms-id/charity/descriptor";
import { logoProduct } from "@comms-id/logo/descriptor";
import { createNextRelay } from "@comms-id/relay/next";
import { sanctionsProduct } from "@comms-id/sanctions/descriptor";
import { createSigner } from "./signer";

export const { POST } = createNextRelay({
  products: [addressProduct, asxProduct, charityProduct, logoProduct, sanctionsProduct],
  // Your rule: who may use these routes. Return true, false (401) or your own Response.
  allow: (request) => request.headers.get("cookie") !== null,
  signer: createSigner(),
  // TEST: a development app's key and the TEST service. Going live: delete this line.
  baseUrl: "https://api-test.comms.id",
});

The signer is your own (the relay takes any function that returns a two-minute token for an audience and scope). This one reads your key from the environment and uses WebCrypto only, so it runs in Node 20+, Workers, Deno and Bun:

// The signer your relay needs: a two-minute EdDSA (Ed25519) token for one product, signed with
// your app's private key. The key is read once from the environment and never leaves this
// module. Runs in Node 20+, Workers, Deno and Bun (WebCrypto only).
import type { RelaySigner } from "@comms-id/relay";

const PADDING = /=+$/;
const encode = (bytes: ArrayBuffer | Uint8Array): string =>
  btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replace(PADDING, "");
const json = (value: unknown): string => encode(new TextEncoder().encode(JSON.stringify(value)));

/** A signer for `@comms-id/relay` from COMMS_ID_CLIENT_ID and COMMS_ID_PRIVATE_JWK. */
export const createSigner = (env: Record<string, string | undefined> = process.env): RelaySigner => {
  const clientId = env.COMMS_ID_CLIENT_ID?.trim();
  const jwk = env.COMMS_ID_PRIVATE_JWK;
  if (!(clientId && jwk)) {
    return () => undefined; // the relay then refuses with NOT_CONFIGURED; nothing is forwarded
  }
  const key = crypto.subtle.importKey("jwk", JSON.parse(jwk), { name: "Ed25519" }, false, ["sign"]);
  return async ({ audience, scope }) => {
    const now = Math.floor(Date.now() / 1000);
    const claims = {
      iss: clientId,
      sub: crypto.randomUUID(),
      jti: crypto.randomUUID(),
      aud: audience,
      scope,
      iat: now,
      exp: now + 120,
    };
    const input = `${json({ alg: "EdDSA" })}.${json(claims)}`;
    const signature = await crypto.subtle.sign("Ed25519", await key, new TextEncoder().encode(input));
    return `${input}.${encode(signature)}`;
  };
};

TEST first: the route above names baseUrl: "https://api-test.comms.id" and your development key answers there. LIVE: delete that line and deploy with the production key. The code between does not change.

LIVE

Server: remove environment: "test". Relay: remove baseUrl. Both with the production key. Reference: Sanctions API. Keep the generation and checkedAt of every screening with the case: “no possible match” is a statement about one generation of the list.

Was this page helpful?