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.