Server-side hooks
hooks on auth({...}) wrap every server-invoked auth operation — authenticate and the imperative kit — with before/after. They are the seam for connection admission, agent-provisioning policy, and audit: an extension a host can't bypass because it wraps the operation itself. Client request handlers (signIn/signUp/signOut/createApiKey) are not hooked — those run over the wire and already have a veto seam in the server's use: middleware chain.
Wire hooks into auth()
hooks is one object on the kit, nested to mirror authKit.<surface>.<method>:
const authKit = auth({
contract: app,
collections: backend,
hooks: {
authenticate: {
before: (handshake) => { if (blocked(handshake.headers['x-forwarded-for'])) throw new SuperLineError('FORBIDDEN', 'blocked') },
after: (result) => ({ ...result, ctx: { ...result.ctx, tenant: tenantOf(result.ctx.userId) } }),
},
users: {
create: { before: (i) => ({ ...i, displayName: i.displayName.trim() }), after: (u) => audit('user.create', u.id) },
deactivate: { after: () => notifySecurity() },
},
credentials: { setPassword: { before: (i) => assertStrong(i.newPassword) } },
apiKeys: { create: { after: (r) => vault.store(r.key) } },
},
})authenticate hooks
before inspects or rewrites the raw Handshake, or throws to reject the connection. after fires for every resolution — guests included — and may transform the result (enrich ctx, override env, change role).
hooks: {
authenticate: {
// before: inspect/rewrite the raw handshake, or throw to REJECT the connection. The transport
// swallows a rejected authentication into a bare 401 (no server-side log, no onError), so a record
// of blocked connections must be logged HERE, before the throw. NB: the WS client may re-attempt a
// rejected handshake, so `authenticate` can run more than once per connect — keep it idempotent
// (this is a pure check, so it is). That's why the audit line below can appear more than once.
before: (handshake) => {
if (handshake.query.banned) {
console.log(' ⛔ authenticate rejected a banned handshake')
throw new SuperLineError('FORBIDDEN', 'connection blocked by policy')
}
},
// after: fires for EVERY resolution (guests included) — a connection audit trail. It can also
// transform the result (enrich ctx, override env); here it only observes.
after: (result) => console.log(' → connect:', result.role, 'via', result.ctx.authMethod ?? 'guest'),
},
}A rejected handshake is refused at the upgrade, which does not carry the server's reason — so the client just sees the connection close as DISCONNECTED, and its first request drops:
const banned = createSuperLineClient(app, { transport: transport(), role: 'guest', params: { banned: '1' }, reconnect: false })
await banned.whoami().then(
() => console.log(' connected?! — the hook should have refused this'),
(err) => console.log(' refused →', (err as SuperLineError).code), // DISCONNECTED — authenticate.before threw
)Log before you throw, and stay idempotent
A rejected authentication becomes a bare 401 — no server log, no onError. Record blocked connections in the before, ahead of the throw. And because the WS client may re-attempt a rejected handshake, authenticate can run more than once per connect: keep the before a pure, repeatable check. reconnect: false surfaces the refusal at once instead of retrying forever.
Hooks on the imperative kit
The provisioning kit trips the nested hooks. before transforms the input (return a new one); after observes:
hooks: {
users: {
// before TRANSFORMS: stamp provenance onto every server-provisioned identity (a returned input).
create: {
before: (input) => ({ ...input, metadata: { ...input.metadata, provisionedVia: 'agent-kit' } }),
after: (user) => console.log(' audit: user.create →', user.displayName),
},
// `deactivate.before` is NON-vetoable: a throw here is routed to `onHookError` and the
// deactivation proceeds anyway — the emergency stop must never be blockable by host code.
deactivate: { before: ({ id }) => console.log(' ⚠ security: deactivating', id, '— revoking everything') },
},
apiKeys: {
create: {
after: (key) =>
// ⚠ the RAW `slp_…` key is right here. Audit the id; NEVER log the whole key —
// `after: (k) => log(k)` would write a live credential to disk. Mirror it to a vault, at most.
console.log(' minted api key', key.id, 'ending …' + key.key.slice(-4)),
},
},
tokens: {
mintSealed: {
after: (token) =>
console.log(' audit: sealed assertion minted, expires', new Date(token.expiresAt).toISOString()),
},
},
}users.create.beforetransforms a returned input (stamp provenance);afterobserves the created row.users.deactivate.beforecannot veto — a throw routes toonHookErrorand deactivation proceeds. The emergency stop for a compromised account must never be blockable.apiKeys.create.aftersees the rawslp_…key in the payload — audit the id, never the key.
Behaviours
beforetransforms or vetoes. Return a new input to transform; throw to veto (nothing is written).authenticate.beforerewrites theHandshake; a throw rejects the connection.afterobserves. A throw propagates to the caller, but the write already committed and stays.authenticate.afteris the exception — it may transform the resolved result (enrichctx, overrideenv, changerole) or reject, becauseauthenticatecommits nothing.- Cascades are silent.
users.deactivateandcredentials.setPasswordinternally revoke keys/tokens/ sessions; those internal writes fire noapiKeys.revoke/tokens.*hooks. Audit the composite, not the leaves. onHookErrordefaults toconsole.error— it catches a non-vetoable throw (deactivate.before) and anyafterthrow you don't want to reach the caller.
Hook payloads carry raw secrets
authenticate.before sees the handshake's bearer tokens (query.jwt / query.apiKey); credentials.*.before sees the plaintext password; apiKeys.create.after sees the raw slp_… key; tokens.*.after sees the minted token. Never log a payload wholesale (after: (r) => log(r) writes a live credential to disk).
Next: Provision an agent identity · back to Choose an auth strategy.