# Show a website's logo with a fallback

What you build: a list of suppliers with each one's logo. The element asks Comms.ID for the logo of a site, shows the site's first letter until the image arrives, keeps the letter when there is no logo, and loads the image from Comms.ID only: the visitor's browser never contacts the supplier's site, so no visitor address leaks to a third party.

## 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 **Logo** 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 Logo 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.

## The page

`logo.html`:

```html
<!doctype html>
<!-- A supplier list with each supplier's logo. The element shows the first letter of the site
     until the image arrives, and keeps the letter when there is no logo; the visitor's browser
     contacts only Comms.ID, never the supplier's site. -->
<ul id="suppliers">
  <li><comms-id-logo relay="/api/comms-id/logo" site="example.com" size="40"></comms-id-logo> Example Pty Ltd</li>
  <li><comms-id-logo relay="/api/comms-id/logo" site="nomatch.example" size="40"></comms-id-logo> No Logo Pty Ltd</li>
</ul>
<p id="logo-note" role="status"></p>

<script src="https://cdn.comms.id/logo/v1.js"></script>
<script>
"use strict";
const note = document.getElementById("logo-note");
for (const logo of document.querySelectorAll("comms-id-logo")) {
  // The letter is the fallback: say why when a reader asks, but never treat it as an error.
  logo.addEventListener("comms-id-fallback", (event) => {
    logo.title = { missing: "No logo on record", blocked: "Logo not loaded", failed: "Logo could not load" }[
      event.detail.reason
    ];
  });
  // A failed call is not "no logo": keep the letter and mention it once.
  logo.addEventListener("comms-id-error", (event) => {
    note.textContent = `Logos are unavailable right now (${event.detail.error.outcome}).`;
  });
}
</script>
```

The box has its size from the first render, so the page never moves while images load. `comms-id-fallback` says why the letter stays (`missing`, `blocked` or `failed`); `comms-id-error` is a failed call, which the page must not show as "no logo"; `comms-id-result` carries the whole reply (`resolution.imageUrl` on `api.comms.id`, `logoUrl` as provenance only, `source`, `canonicalUrl`).

## 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.

## TEST first

With the relay pointed at TEST, `example.com` (the published example) resolves to the fixed TEST image at `https://api-test.comms.id/logo/v1/image/SYNTH-LOGO-1`; `nomatch.example` has no logo and keeps the letter; `unavailable.example` is the outage branch. Add `https://api-test.comms.id` to your `img-src` while you test.

A page with no server: `<comms-id-logo pk="pk_test_…" base-url="https://api-test.comms.id" site="example.com">`, then `pk_live_` in production.

## LIVE

Remove `baseUrl` from the relay route and deploy with the production key; `img-src https://api.comms.id` is the only image host the element needs. Reference: [Logo API](/docs/reference/logo).
