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

Show a website's logo with a fallback

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:

<!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):

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

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.

Was this page helpful?