Receiving Events

The receiver, the route it answers on, and the three things that catch everyone.

A session ends because the provider tells you so, over a back channel, with a signed token. The receiver verifies that token and hands you the event; the route it answers on is yours. This page is the SDK side — for the protocol and the event shapes, see Receiving events.

import { Guacamole, RedisReplayStore, SESSION_REVOKED, subjectOf } from "@guacamole_hbai/auth";

const receiver = gid.receiver({
  audience: "https://app.acme.com",
  replayStore: new RedisReplayStore(redis),
});

receiver.on(SESSION_REVOKED, async (event, claims) => {
  await sessions.revokeAllFor(subjectOf(claims));
});

export const POST = receiver.handler();

audience must be the value the stream was registered with. It is the one string that has to agree in two places, which is why streams.ensure takes the receiver itself rather than asking you to repeat it.

gid.receiver(…) derives the issuer from the client, which already knows your base URL and tenant. Where no API key belongs — a web tier that verifies events while a separate worker provisions people — use new Receiver({ issuer, … }) instead. A receiver needs no credentials.

The route

receiver.handler() returns (Request) => Promise<Response>, which is the Next.js, Hono, Bun, Deno and Workers adapter. There is no framework-named subpath.

export const POST = receiver.handler();

For Express, Connect, Fastify and raw node:http:

app.post("/api/events", receiver.nodeHandler());

Either one reads the raw body, echoes the status the receiver decided, answers 405 to a GET without touching the body, and never throws. receiver.receive(rawBody) remains for a framework neither shape fits — if you call it directly, the three rules below become yours to honour.

Three things that catch everyone

Return the status the receiver decided

The codes are inverted from intuition, and getting one wrong is silent:

You return The provider does
2xx delivered, done
4xx permanent — marks the event failed and never retries
5xx retried, up to six times

So a duplicate is 202. An event about a user you have never heard of is 202. And your own database being down must be 5xx — answering 400 there tells the provider the event is unprocessable forever, and a real revocation is discarded. The handlers do this for you; deriving your own status is how it goes wrong.

Pass the raw bytes

In Express that means registering the body parser for the right type:

app.use(express.text({ type: "application/secevent+jwt" }));

Without it req.body is {}, the signature verifies against nothing, and every event fails to parse. It is the single most likely integration failure there is, so nodeHandler() detects it and says so by name rather than answering a generic refusal.

Handlers must be idempotent

The replay store marks a jti after your handlers run, so a retry caused by a lost acknowledgement runs them a second time. outcome.duplicate is an observation for your metrics, not a guard that skipped work.

Replay stores

No replay store is the default.

gid.receiver({ audience, replayStore: "memory" });        // one process
gid.receiver({ audience, replayStore: new RedisReplayStore(redis) });

"memory" is shorthand for a single process — a test, a local run, a first integration — and it warns for itself when NODE_ENV says production. Behind more than one worker each has its own copy, so a redelivery reaching a different one is processed twice. InMemoryReplayStore is the same thing spelled out.

Reading an event

Comparing against a string literal does not fail when it is wrong — it silently never matches, and the symptom is an integration that appears to work until the event you cared about arrives. The vocabulary is exported for that reason.

import {
  CREDENTIAL_TYPE_FACE_TEMPLATE,
  changeTypeOf, credentialTypeOf, isPresenceLost, reasonCodeOf, sessionIdOf,
} from "@guacamole_hbai/auth";

Every reader tolerates a malformed event rather than throwing, so a surprising payload cannot turn your 202 into a 500.

Two are worth naming:

isPresenceLost(event) — an explicit current_level: null means nobody is there. An absent key means the event did not carry one. !current_level matches both, so the shorthand ends a session over a parsing accident.

sessionIdOf(event) — which watch this is about. One person can have two at once: a second tab, or a step-up beside a running session. Keying your state on this is what replaces guessing from timing.

When a watch ends

A watched session can end for several reasons, and only some of them are worth asking the person to sign in again for.

import { canReauthenticate, reasonCodeOf } from "@guacamole_hbai/auth";

receiver.on(SESSION_REVOKED, async (event, claims) => {
  await sessions.revokeAllFor(subjectOf(claims));
  if (canReauthenticate(reasonCodeOf(event))) inviteSignIn();
});

canReauthenticate is true for a mismatch, which reads like the one case it should not be — the provider has just said somebody else may be at the camera. But signing in again is a gate, not a retry: the enrolled person passes it in seconds, anybody else fails it, and you are told about that too. The commoner case by far is the right person scoring just under the threshold while sitting slightly off-angle.

It is false only where the person's own state ended it, where a new sign-in would be refused as well.

Bound the retries. Every ended watch has already sent you a session-revoked. An unbounded restart loop fills your own receiver with revocations of somebody legitimate, seconds apart, which reads exactly like an attack in progress.

Test it before a revocation depends on it

await gid.streams.verify({ clientId: "gid_…", state: "deploy-check" });

The provider signs and pushes a real event down the same path a revocation takes. It names nobody — subjectOf returns null, because a verification describes the channel rather than a person.