HTTP hooks
Hooks let you plug custom logic into pykrete without changing the backend: the backend POSTs a JSON document
to the frontend (freshfront) at /hooks/<name> and uses the JSON answer. The default implementations live in
freshfront/routes/hooks/*.ts; edit them to customize behavior.
Hooks at a glance
| Hook | Fires | On failure | Default implementation |
|---|---|---|---|
|
A user was identified in an interactive flow: sign-in page, |
The flow is aborted |
Does nothing |
|
Every successful token request ( |
The token request fails with |
Adds NetBird claims, see NetBird |
|
An organization invitation with an |
The invitation is deleted, creation fails |
Renders the bundled invitation email |
|
A user added an email address or asked to resend the verification link |
The link is invalidated, the user sees "Could not send the verification link, try again later" |
Renders the bundled verification email |
There are no other hook points.
The emails/* hooks only render content. The backend sends the result over SMTP, see Email
hooks and Email configuration.
Delivery
Configuration
Hooks are configured in the backend's config file (see Configuration):
{
"session_secret": "<session-secret>",
"frontend": {
"upstream": "unix:/run/pykrete/freshfront.sock",
"hooks_secret": "<hooks-secret>"
}
}
upstream is either unix:<path> or http://<host>:<port> (plain HTTP, no path). It is the same upstream the
backend proxies all unhandled requests (UI pages, docs) to; hooks reuse it.
Give freshfront the same secret in the HOOKS_SECRET environment variable; it is read once at startup.
freshfront's HOOKS_SECRET must match the backend's frontend.hooks_secret; otherwise every hook fails, and
with it every sign-in and token request. Set both or neither.
Hooks are disabled when frontend is missing or hooks_secret is missing or null. Then:
-
identityandclaimsare skipped (no custom claims). -
Emails can't be rendered. If
emailis configured, sending fails ("sending email requires frontend hooks to render it"). If it isn't, the backend logs the email's raw data, including the link, atinfolevel ("email delivery is not configured") and reports success.
Request
POST /hooks/claims HTTP/1.1
Host: frontend
Authorization: Bearer <hooks-secret>
Content-Type: application/json
{"grant_type": "authorization_code", ...}
Each call opens a new HTTP/1.1 connection to the upstream.
Response and failure
The hook must answer with a 2xx status and a JSON body within 5 seconds; the body may be at most 1 MiB. null
is a valid body. Anything else fails the hook:
-
a non-
2xxstatus (including redirects), -
an empty body (
204 No Contentcounts as failure: the body must be JSON), -
invalid JSON or, for
claimsandemails/*, JSON of the wrong shape, -
connection errors or the 5 second timeout.
Hooks fail closed: what happens next is listed in the table above. The backend logs the cause, for example
hook: hook returned 404 Not Found.
The freshfront middleware (freshfront/routes/hooks/_middleware.ts) compares the Authorization header with
Bearer $HOOKS_SECRET (constant time) and answers 404 on mismatch or when HOOKS_SECRET is unset.
Keeping hooks internal
The backend never proxies /hooks or /hooks/... from outside: such requests get 404, also after path
normalization (/x/../hooks/..., %2e segments, backslashes, double slashes).
freshfront's own listener serves /hooks/* too. Without FRESHFRONT_SOCKET it listens on TCP port 8000 on all
interfaces, so run it on a unix socket (FRESHFRONT_SOCKET=/run/pykrete/freshfront.sock with
"upstream": "unix:/run/pykrete/freshfront.sock") or firewall the port. See
Running.
identity
Called at the end of every interactive identification, also when the user was already signed in and nothing was shown.
{"user_id": "U0123456789abcdefghijklmnopqrstuv"}
The response body is ignored (return null). A failure aborts the flow: /authorize redirects to the client
with error=server_error ("internal error, try again in a moment"), other flows show "Internal error, try again
later". This makes the hook usable as a sign-in gate, for example to block suspended users. It does not run on
token refresh; to cut off existing grants as well, fail the claims hook for that user.
claims
Called by the token endpoint after the grant, scopes and organization were validated and pykrete's own claims were built, and before the tokens are signed. It runs for refresh too, so claims stay fresh.
Request
{
"grant_type": "authorization_code",
"client_id": "C0123456789abcdefghijklmnopqrstuv",
"audiences": ["https://api.example.com"],
"scope": "openid organizations profile",
"user": {
"id": "U0123456789abcdefghijklmnopqrstuv",
"public_metadata": {},
"private_metadata": {"plan": "enterprise"}
},
"organization": {
"id": "O0123456789abcdefghijklmnopqrstuv",
"slug": "acme",
"name": "Acme Inc.",
"public_metadata": {"tenant": "acme-eu"},
"private_metadata": {"netbird": {"domain": "acme.netbird.example.com"}},
"roles": ["role:org-admin"]
},
"organizations": ["O0123456789abcdefghijklmnopqrstuv", "O9876543210zyxwvutsrqponmlkjihgfe"]
}
| Field | Meaning |
|---|---|
|
|
|
Client the tokens are issued to |
|
Audience identifiers ( |
|
Space-separated granted scopes |
|
The user and their metadata ( |
|
The active organization (see Organizations) with both metadata objects and the user's
role ids in it, or |
|
Ids of the organizations listed in the |
Response
{
"id_token": {"groups": ["role:org-admin"], "tenant": "acme-eu"},
"access_token": {"tenant": "acme-eu"}
}
Both keys are optional; other top-level keys are ignored. null or {} adds nothing. Values can be any JSON.
Reserved claims
The whole token request fails (fail closed) if the hook returns, for either token:
-
a reserved name:
iss,sub,aud,exp,iat,nbf,jti,azp,scope,nonce,auth_time,at_hash,c_hash,acr,amr,sid,orgs; -
any name starting with
org_; -
a name pykrete already set in that token. In the ID token these are
name,picture,updated_at(scopeprofile),email,email_verified(scopeemail),orgsandorg_*; in the access tokenorg_*.
The error the client sees is server_error; the backend log says hook: hook tried to set reserved claim <name>.
Where the claims end up
-
id_tokenclaims go into the ID token and are returned by/userinfo. They are dropped when theopenidscope was not granted (no ID token is issued). -
access_tokenclaims go into the access token JWT. Token introspection does not report them. -
Claims are stored with the issued token; a refresh calls the hook again.
A failed claims hook answers the token request with HTTP 400 and
{"error": "server_error", "error_description": "Something is wrong with the server, try again later"}. For
the code grant the authorization code is already used up and the user has to sign in again; a refresh token is
not rotated, so the client can retry.
Email hooks
pykrete sends email itself, but freshfront writes it. For each email the backend POSTs the data to
/hooks/emails/<kind>. freshfront answers with the rendered message, and the backend wraps it into a MIME
message and sends it over SMTP. The backend sets From (from email.from), To, Date and Message-ID, so a
template can't change the recipient or the sender.
Every request has to, the recipient address, plus the fields of that kind. expires_at is a unix timestamp in
seconds. url contains a secret: anyone who gets it can use it, so do not log it.
The response must be:
{
"subject": "Confirm your email address",
"text": "Open this link to confirm the address: ...",
"html": "<!DOCTYPE html><html>...</html>"
}
-
subject: required, non-empty, no control characters (no line breaks). -
text: required, non-empty. Sent as thetext/plainpart. -
html: optional. When present and non-empty, the message ismultipart/alternativewith both parts.
A response that breaks these rules fails the hook like any other hook failure.
emails/invitation
Called when an organization invitation with an email is created, by an organization member on
/account/organizations/<slug>/invitations, through manage { organization { invite } }, or by an admin via
orgs { createOrganizationInvitation }. Link-only and user-bound invitations do not call it. See
Invitations.
{
"to": "alice@example.org",
"organization": {"id": "O0123456789abcdefghijklmnopqrstuv", "slug": "acme", "name": "Acme Inc."},
"inviter": "Bob",
"url": "https://auth.infra.delta.rocks/invite#<code>",
"expires_at": 1793601600
}
inviter is the display name of whoever created the invitation, or null. The invited person has to sign in
with an account that has to as a verified address. If rendering or sending fails, the invitation is deleted
and the creator sees "Could not send the invitation email, try again later"; the cause is only in the backend
log.
emails/email-verification
Called when a user adds an email address (me { addEmail }) or asks for a new link
(me { resendEmailVerification }), both on the account email page.
{
"to": "alice@example.org",
"url": "https://auth.infra.delta.rocks/account/emails/verify?code=<code>",
"expires_at": 1793083200
}
The link is valid for 24 hours and only for the signed-in owner. A new link for the same address can be sent at most once a minute, and a user can have at most 5 unverified addresses. If rendering or sending fails, the new link is invalidated.
Customizing hooks
Edit the freshfront handlers
Each hook is a Fresh route in freshfront/routes/hooks/: identity.ts, claims.ts,
emails/invitation.ts, emails/email-verification.ts. The _middleware.ts in the same directory already
checks the secret, so handlers only deal with the payload.
Example: keep the NetBird mapping and add a tenant claim from the active organization's public metadata
(freshfront/routes/hooks/claims.ts, only the handler changes):
export const handler = define.handlers({
async POST(ctx) {
const hook: ClaimsHook = await ctx.req.json();
const netbird = netbirdClaims(hook);
const tenant = hook.organization?.public_metadata.tenant;
const extra: Claims = typeof tenant === "string" ? { tenant } : {};
const output: ClaimsHookOutput = {
id_token: { ...netbird, ...extra },
access_token: { ...netbird, ...extra },
};
return Response.json(output);
},
});
Keep handlers well under the 5 second budget: the backend gives up after 5 seconds and treats it as a failure.
Change email templates
The email routes only parse the request and call a template from freshfront/server/emails.tsx, which exports
the request types and one function per email returning {subject, text, html}.
The HTML part is baked once when freshfront starts: Template.bake from @delta-docs/build-selfcontained/template
renders the Preact JSX, moves the rules of freshfront/server/emails.css into style attributes (many mail
clients ignore stylesheets), and embeds a Forkex Sans subset holding only the glyphs the email can show. A request
only fills in the placeholders.
-
Mark request values with
<Placeholder name="..." expectedCharacters={...} />in text andplaceholder("...")in attributes.expectedCharacterspicks the glyphs that go into the font: the inside of a regex character class ("a-zA-Z0-9"), aRegExp, or a preset fromchars(ascii,latin,latinExtended,cyrillic,greek,date(format)). Other characters still show, in the fallback font. -
Values derived from the request (the host of
url, the formatted expiry) are placeholders of their own: compute them in the email function and pass them torender. -
Write colors as
light-dark(<light>, <dark>); the palette variables from@delta-docs/chrome/colors.cssalready are. Light values are inlined, dark values go into aprefers-color-scheme: darkblock that Apple Mail, Outlook for Mac and Thunderbird apply. Gmail and Outlook for Windows ignore it and invert colors on their own. -
Use tables for layout, mail clients handle flexbox and grid poorly.
Every expected character adds a glyph to each weight that can show it. The bundled invitation is about 39 KB with
chars.latin for names and about 58 KB with chars.latinExtended; Gmail clips messages over about 102 KB.
Example: a different invitation subject.
export function invitation(email: InvitationEmail): RenderedEmail {
const org = oneLine(email.organization.name);
return {
subject: `Your invitation to ${org}`,
// ...
};
}
Strip control characters from anything user-controlled that goes into subject (the bundled oneLine helper
does that); a subject with a line break fails the hook. render escapes values in the HTML part.
The freshfront dev server lists every email with sample data at /dev/emails, with the text part inline and a
link to the HTML part. The page answers 404 outside the dev server.
Serve hooks from another service
There is no per-hook URL setting: hooks always go to frontend.upstream, which also serves the UI. To run hooks
elsewhere, either
-
call your service from the freshfront handler, or
-
point
upstreamat a reverse proxy that sends/hooks/*to your service and everything else to freshfront. Your service then has to checkAuthorization: Bearer <hooks-secret>itself and follow the request and response formats on this page.
A minimal standalone claims hook with Deno:
const secret = Deno.env.get("HOOKS_SECRET");
async function digest(value: string) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
);
}
async function authorized(req: Request) {
if (!secret) return false;
const [a, b] = await Promise.all([
digest(req.headers.get("authorization") ?? ""),
digest(`Bearer ${secret}`),
]);
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
Deno.serve({ hostname: "127.0.0.1", port: 9000 }, async (req) => {
const { pathname } = new URL(req.url);
if (req.method !== "POST" || !await authorized(req)) {
return new Response(null, { status: 404 });
}
const hook = await req.json();
switch (pathname) {
case "/hooks/claims": {
const plan = hook.user.private_metadata.plan;
const claims = typeof plan === "string" ? { plan } : {};
return Response.json({ id_token: claims, access_token: claims });
}
case "/hooks/identity":
return Response.json(null);
case "/hooks/emails/invitation":
case "/hooks/emails/email-verification":
return Response.json({
subject: "Action needed for your account",
text: `Open this link: ${hook.url}`,
});
}
return new Response(null, { status: 404 });
});
/hooks/* must answer all four hook names, or sign-in
(identity), token issuance (claims) or email fail.Troubleshooting
| Symptom | Likely cause |
|---|---|
Every sign-in ends with "Internal error" or |
|
Token requests fail with |
The claims hook returns a reserved name or one pykrete already set |
Log shows |
A hook answered with an empty body; return |
Log shows |
The hook took longer than 5 seconds |
Emails never arrive, backend log shows |
No |
Sending fails, log shows |
|
Sending fails, log shows |
The template put a line break or other control character into the subject |
Sending fails, log shows |
Wrong |