# 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`:

```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

```ts
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:

```tsx
// 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):

```ts
// 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:

```ts
// 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](/docs/reference/sanctions). Keep the `generation` and `checkedAt` of every screening with the case: "no possible match" is a statement about one generation of the list.
