@thejob/auth
v1.0.0
Published
Shared auth for The Job services: Better Auth JWT verification middleware + user lifecycle events over Redis Streams.
Readme
@thejob/auth
Shared auth for The Job backend services. Two concerns, split across subpath exports so a service only pulls the module (and dependencies) it uses:
| Import | Contains | Extra deps |
| --- | --- | --- |
| @thejob/auth | Better Auth JWT verification + Hono guards | jose |
| @thejob/auth/events | event names + payload types/schema | none |
| @thejob/auth/events/producer | emit() over a Redis Stream | ioredis |
| @thejob/auth/events/consumer | on()/start() consumer group | ioredis |
The package lives inside the thejob-hono-auth repo (packages/auth/) and is
consumed via yalc locally or the registry — the same pattern data-processor uses
for its client/ package.
Verification (@thejob/auth)
Replaces the byte-identical lib/auth.ts that used to be copy-pasted into every
Hono service. Build the helpers once with your service's config, then use the
returned Hono middleware:
import { createAuth } from '@thejob/auth';
const auth = createAuth({ authUrl: env.AUTH_URL, logger });
app.get('/me', auth.requireUser, (c) => c.json(auth.getUser(c)));
app.get('/pub', auth.attachUser, (c) => c.json({ user: auth.getUser(c) ?? null }));
app.patch('/x', auth.requireAdmin, (c) => c.json({ ok: true }));attachUser— best-effort; setsc.var.userwhen a valid token is present.requireUser— 401 unless a valid token identifies a user.requireAdmin— 401 unauthenticated, 403 unless the user has theadminrole.
Tokens are EdDSA JWTs verified against Better Auth's JWKS at
${authUrl}/api/auth/jwks. Config is injected via createAuth(...); the
package never reads process.env itself.
Events (@thejob/auth/events/*)
Better Auth publishes user-lifecycle events onto a Redis Stream
(thejob:auth.events); downstream services consume them with a consumer group.
The producer knows nothing about consumers. No signing — the stream is internal
infra (trust = network + Redis auth); consumers still schema-validate every event.
Producer (auth):
import { createAuthEventsProducer } from '@thejob/auth/events/producer';
const producer = createAuthEventsProducer({ url: env.AUTH_REDIS_URL });
await producer.emit('user.created', { authAccountId: user.id, data: { email, name, roles } });Consumer (e.g. user-service):
import { createAuthEventsConsumer } from '@thejob/auth/events/consumer';
const consumer = createAuthEventsConsumer({ url: env.AUTH_REDIS_URL, group: 'user-service', logger });
consumer.on('user.created', applyEvent);
consumer.on('user.updated', applyEvent);
consumer.on('user.deleted', applyEvent);
await consumer.start();Event types: user.created, user.updated, user.deleted. Each carries
{ type, id, occurredAt, authAccountId, data? } where authAccountId is Better
Auth's user.id (the trusted join key) and data holds auth-managed fields
(email, name, roles).
Durability
- Consumer group — each event is delivered to the group once; a backlog accumulated while a consumer was down is drained on restart.
- At-least-once — an entry is
XACK'd only after its handler resolves; a throwing handler leaves it pending and it is redelivered viaXAUTOCLAIM. Handlers must therefore be idempotent. - Bounded —
XADD … MAXLEN ~self-trims the stream.
Config
Every Redis URL is injected via the constructor from the calling service's own
AUTH_REDIS_URL env — the library never reads it from process.env. This keeps
each service's config self-contained and the package transport-agnostic.
Build & test
pnpm build # tsc -> dist (with .d.ts)
pnpm test # vitest (verification guards + Redis Streams behaviour)
pnpm typecheckThe events tests need a local Redis on localhost:6379 (override with
TEST_REDIS_URL); they skip cleanly when none is reachable.
