-
Notifications
You must be signed in to change notification settings - Fork 2
Pact Hooks
The storage seam: flat, optional, Promise-friendly functions that connect pact to your database. pact calls them, optionally caches what they return (see Caching), and never owns a schema. Ready-made tables for every hook are in Storage.
Every hook is optional. A feature whose hooks are missing throws
MISSING_HOOK the first time it is used, so misconfiguration is loud and
immediate.
| Feature | Hooks required |
|---|---|
hasPermission / assert / principalOf
|
getPrincipal, or getUser, or getApiKey
|
register |
getUser + createUser
|
login / verifyCredentials
|
getUser, plus a session store (below) |
| Session store |
saveSession + getSession + deleteSession — or a session cache TTL (cache-only mode) |
authenticate BEARER
|
the session store + getUser/getPrincipal
|
authenticate BASIC
|
getUser |
authenticate APIKEY / HMAC
|
getApiKey |
issueApiKey / revokeApiKey
|
saveApiKey / revokeApiKey
|
logoutAll |
deleteSessions |
setPassword / password reset |
setPassword (+ saveResetToken / consumeResetToken for the reset flow) |
| Email verification |
getUser + saveResetToken / consumeResetToken — the status change is yours (no status hook) |
verifyMFA |
getUser, plus claimTotpStep + countMfaAttempt + resetMfaAttempts when more than one process runs (otherwise tracked per process) |
| OAuth login |
getUser (+ createUser when autoProvision is on, and optionally oauthIdentifier) |
| Passkeys (all four ceremonies) |
getPasskey + getPasskeys + savePasskey + updatePasskeyCounter + getUser — checked at construction; finishPasskeyLogin additionally needs the session store |
All shapes are exported from @tundralibs/pact/types.
| Type | Purpose |
|---|---|
PactStoredUser |
id, status, passwordHash?, mfaSecret?, grants, metadata?
|
PactStoredApiKey |
id, userId?, status, secret (raw at this boundary), grants, metadata?
|
PactStoredSession |
id (token sha-256), userId, expiresAt, generation?, rotatedAt?, metadata?
|
PactStoredResetToken |
id (token sha-256), userId, purpose (PASSWORD_RESET | EMAIL_VERIFICATION), expiresAt
|
PactStoredPasskey |
id (credential id), userId, publicKey (JWK string), algorithm, signCount, transports?, metadata?
|
PactUserQuery |
{by:'ID'} | {by:'IDENTIFIER'} | {by:'OAUTH', provider, subject}
|
PactCreateUserInput |
What createUser receives, including the OAuth link on JIT provisioning |
PactPrincipal |
What getPrincipal returns: kind, id, per-module bigint grants (keys may be tenant-scoped) |
grants on stored records is the serialized form — a JSON object of module
name to decimal bit-string, produced by serializeGrants and parsed by
deserializeGrants. Deserialization drops __proto__/constructor/
prototype keys and caps masks at 100 digits.
The authoritative contracts live in the PactHooks JSDoc; the load-bearing
points:
-
getPrincipal(id)returns a fully composed principal: effective per-module masks with groups/roles already folded in. Returnnullfor an unknown actor or one that must not authorize. When bothgetPrincipalandgetUserexist,getPrincipalwins for id-based resolution. -
getUser(query)serves three discriminated lookups: by id, by login identifier, and by OAuth link (provider+subject). Returnnullfor no match; never throw for absence. -
createUser(input)persists and returns the stored record. On OAuth JIT provisioninginput.oauthcarries the link (provider,subject, normalizedprofile) — store it so theby: 'OAUTH'query finds the user next login. -
oauthIdentifier(identifier, profile)maps the identifier pact derives for a JIT-provisioned user before the duplicate check andcreateUser. Use it to scope accounts per tenant; see Tenants. -
getApiKey(keyId)returns the record withsecretdecrypted — see How secrets are stored. Thegrantsit returns are what the key may do. pact refuses a key whose owner is inactive, but does not compare the key's grants with the owner's: to cap a key at its owner's current grants, or suspend it with its tenant, computegrantsandstatushere. With anapiKeycache TTL, changes apply when the cached entry expires. -
saveSession(session)should be an insert (or a conditional/keyed write), not a blind upsert of arbitrary ids: session ids are pact-minted, and a blind upsert lets a deleted session be resurrected by a late write racing a logout. -
consumeResetToken(id)returns and deletes in one motion, which is what makes action tokens single-use even under concurrent attempts. It must be atomic, e.g.DELETE … WHERE id = $1 RETURNING *; a read then a delete lets two concurrent resets both succeed. Return the record whatever itspurpose: pact checks the purpose after consumption, so a reset token presented toverifyEmail(or the reverse) is rejected and burned in the same motion. -
claimTotpStep(userId, step)atomically storesstepas the user's last accepted TOTP step only if it is later than the stored one, and returns whether it did:UPDATE users SET totp_step = $2 WHERE id = $1 AND (totp_step IS NULL OR totp_step < $2), true when a row changed. -
countMfaAttempt(userId, window)counts one attempt and returns the count in the live window, starting awindow-second one when none is live (redisINCR+EXPIRE NX);resetMfaAttempts(userId)clears it after a success.
Actor ids share one namespace across kinds: a user id and an API-key id
must never collide (pact's generated pact_ak_... key ids make this true
by construction; prefix your own ids if you mint them yourself).
Each credential kind has one correct storage treatment, and pact's boundary shapes assume it:
| Value | Treatment | Why |
|---|---|---|
| Password | pbkdf2 hash (pact hashes it for you) | Verification only ever compares hashes |
| API-key secret | Encrypt at rest, app-side | APIKEY comparison and HMAC recomputation both need the raw bytes |
TOTP seed (mfaSecret) |
Encrypt at rest, app-side | TOTP computation needs the raw seed |
| Session / action tokens | Nothing — the stored id is the token's sha-256 |
The raw token is shown once and never stored |
Hooks return raw secrets: decrypt inside getApiKey/getUser, encrypt
inside saveApiKey/your enrollment write. pact never sees or owns your
encryption keys. The AWS SigV4 model is the precedent for retrievable API
secrets: one secret serves both presented-secret and signature schemes.
- Return
nullfor absence; throw only for real faults (a down database). A thrown hook error surfaces to the caller unchanged — it is never swallowed into a false "denied". - Hooks may be sync or async; pact awaits everything.
- Statuses are yours. pact only asks "is this status in
activeStatuses";PENDING,LOCKED,TRIALand friends are app vocabulary. - After changing an actor's grants or status in storage, call
invalidatePrincipal(id)— with caching on, pact cannot see your writes. See Caching.