Imported from orif1n/kirim-saas (
apps/api/AGENTS.md). Install upstream withnpx skills add orif1n/kirim-saas --skill api. Copyright stays with the author.
@saas/api
Hono server. OpenAPI-first via @hono/zod-openapi. Auth via Better Auth. Payments via @saas/payments. All packages come together here — nowhere else.
Structure
src/
├── server.ts Process entry. loadEnv → buildServices → createApp → Bun.serve.
├── app.ts buildServices() + createApp(services). No I/O at import time.
├── context.ts AppServices, AppEnv (Hono generics).
├── middleware/
│ ├── error.ts AppError → JSON. Stack traces only in NODE_ENV === 'development'.
│ ├── session.ts attachSession + requireSession.
│ ├── rate-limit.ts rateLimit() + authRateLimit() fixed windows.
│ ├── rate-limit-store.ts MemoryStore (dev/test) + RedisStore (prod). Injected via services.
│ │ Both expose ping() for the readiness endpoint.
│ └── request-logger.ts Production JSON access log (one line/request, no
│ query strings). Dev uses hono/logger instead.
├── lib/
│ ├── active-org.ts requireActiveOrg() + requireRole() helpers.
│ ├── audit.ts recordAudit() — fire-and-forget append to audit_logs.
│ ├── notifications.ts notifyUser() / notifyUsers() — fire-and-forget notifications insert.
│ ├── scopes.ts requireScope() — bearer scope guard (see "Bearer auth").
│ ├── session-source.ts sessionSource ctx var + requireCookieAuth() for sensitive endpoints.
│ └── redis.ts createRedis() — required at boot in production for rate-limit store.
└── routes/
├── health.ts GET /api/health (liveness, no I/O) + /api/health/ready
│ (readiness: SELECT 1 + rate-limit store ping, 503 on failure)
├── me.ts GET /api/me + GET /api/me/stats (auth required)
├── billing.ts GET /plans, /current, /payments, POST /checkout
├── notifications.ts GET list, GET unread-count, POST mark-read, POST mark-all-read
├── audit.ts GET list of audit events (workspace-scoped, actor JOINed)
├── api-keys.ts POST create / GET list / DELETE personal access tokens
├── files.ts POST /files/upload-url + finalize/remove (presigned via @saas/storage)
└── webhooks.ts POST /webhooks/payment (provider-agnostic)
Auth routes (/api/auth/*) are served directly by the Better Auth handler mounted in createApp.
Middleware order — DO NOT SHUFFLE
1. logger — first, so we see every request (hono/logger in dev, JSON requestLogger in prod)
2. secureHeaders() — CSP/HSTS/X-Frame-Options before any response leaves
3. cors() — webhook path first with no credentials, then everything else with credentials
4. services attach — every handler gets c.get('services')
5. authRateLimit() — /api/auth/sign-in|sign-up|forget-password
6. rateLimit() — general per-IP, skips /api/webhooks
7. attachSession — resolve session cookie into c.get('session')
8. Better Auth handler — /api/auth/*
9. app.route(...) — our routes
10. app.onError() — last resort formatter
Non-negotiable settings
- HTTPS assertion in production.
buildServicesthrows ifNODE_ENV === 'production'ANDAPI_URLis nothttps://. This prevents deploys that silently issue non-Secure cookies. /api/docsgated. In production, neither/api/openapi.jsonnor/api/docsare mounted. Server startup log also skips advertising them.- CORS split. Webhooks get
origin: '*',credentials: falsebecause Duitku etc. do not send cookies. Everything else usesAUTH_TRUSTED_ORIGINSwith credentials. - Rate limit split. Auth endpoints (
/api/auth/sign-in/*,/api/auth/sign-up/*,/api/auth/forget-password): 10/min per IP (stricter). Other business endpoints:RATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MS. The general limiter EXEMPTS all/api/auth/*(Better Auth's own flows) and/api/webhooks/*(providers retry-burst). Only the strictauthRateLimitgates the three auth endpoints listed above. - Rate limit store required in production.
buildServicesthrows at boot ifNODE_ENV === 'production'AND no Redis is reachable — the in-memory store is unsafe across replicas. Dev/test useMemoryStoretransparently. - Storage CDN required in production.
buildServicesthrows ifNODE_ENV === 'production'ANDSTORAGE_PUBLIC_URLis unset. Falling back to signed public URLs would defeat CDN caching and leak long-lived capability URLs.
Design rules
- Handlers do not construct services. All dependencies come from
c.get('services'). This is what makes tests trivial. - All errors are
AppError. The error middleware is the ONLY place that formats responses. Never writec.json({ error: ... }, 400)directly in a handler. - Every route is declared with
createRoute(...). OpenAPI spec is generated automatically at/api/openapi.json; Swagger UI at/api/docs. Skip either and the spec is incomplete. - Payment webhook is provider-agnostic. The route calls
payments.parseWebhook(...); the adapter verifies signatures. The route validates business fields (amount/currency vs pending row) and writes DB. Seepackages/payments/AGENTS.md. - Payment idempotency. Uses
payments.provider_refunique index. Replaying a webhook is safe. Subscriptions upsert on(provider, provider_ref)— safe re-activation. - Auth guards. Read-only endpoints use
requireActiveOrg. Endpoints that change workspace state userequireRole(services, session, 'admin'). Never trust therolefield on the client-side session.
Bearer auth
attachSession in middleware/session.ts checks Authorization: Bearer <token> FIRST. If sha256(token) matches an api_keys.hashed_key row,
a synthesized session is set on the context and sessionSource is
tagged 'bearer'. If not, the middleware falls through to Better Auth's
cookie resolver and sessionSource is 'cookie'.
Bearer callers inherit the KEY CREATOR's tenant role at request time.
If the creator was admin when the key was minted but is now member,
the key effectively downgrades on the next request. If the creator has
been removed from the workspace, the key stops working entirely.
On TOP of the inherited role, each key carries an explicit scope array
stored in api_keys.scopes (JSON-encoded, parsed via
parseScopes from @saas/shared/api-keys). Defaults to ['read'];
write is opt-in at create time. The parsed array is attached to the
Hono context as apiKeyScopes during bearer auth.
Every mutation handler on the bearer surface MUST call
requireScope(c, 'write') from lib/scopes.ts BEFORE the role check.
Order matters: a read-only key hitting a write endpoint should see a
"scope missing" 403, not a misleading role rejection. Cookie sessions
short-circuit requireScope and are governed by role alone. Read-only
(GET) handlers do NOT call requireScope — a ['read'] key is expected
to work.
Sensitive identity flows (password change, account deletion, email
change) are served by Better Auth under /api/auth/* where the
synthesized bearer session is invisible — bearer keys cannot reach them
today, so requireCookieAuth has zero call sites BY DESIGN. Any CUSTOM
sensitive route added outside Better Auth (MFA enrolment, data export,
anything mutating the actor's own identity) MUST call
requireCookieAuth(c) from lib/session-source.ts to reject bearer
callers REGARDLESS of scope — see the helper's doc comment for the
pattern. The API-key surface is for programmatic reads/writes against
workspace data, NOT for identity-management flows.
last_used_at on api_keys is throttled inside the middleware — updates
only fire when the previous stamp is more than a few minutes old, so
bearer traffic doesn't turn every read into a write. See the
implementation in middleware/session.ts.
Audit events
lib/audit.ts exposes recordAudit(services, event). Callers pass
Pick<AppServices, 'db'> — the helper deliberately does NOT take the
full services bundle so it stays trivially callable from anywhere,
including Better Auth's org hooks in packages/auth/src/server.ts (via
the onAuditEvent callback wired in app.ts).
Rules:
- Emit AFTER the mutation commits, not before. An audit row for a mutation that then rolled back is worse than no audit row.
- The write is fire-and-forget. Failures are logged but never
propagate — a broken audit table must not block a business action.
Corollary: never read
audit_logsback to make a decision. It is observability, not truth. Seepackages/db/AGENTS.md"Audit log — append-only". - Actions are stable
snake_casestrings. Route emitters:api_key_created,api_key_revoked(api-keys.ts),subscription_plan_changed,subscription_canceled(billing.ts). Org-plugin hook emitters (AuthOrgAuditActionunion inpackages/auth/src/server.ts):invite_sent,member_role_changed,member_removed,workspace_updated. Every new action string needs a translation inapps/web/src/i18n/locales/{en,id}/activity.tsunderactions.<name>AND — if it's an org-plugin event — a matching case added to theAuthOrgAuditActionunion AND itsKNOWN_ACTIONSfilter inapps/web/src/routes/app.activity.tsx. No dot-separated actions.
Two emission paths:
- Direct call from a route — e.g.
billing.tscallsrecordAuditafter the subscription mutation commits. - Better Auth org hooks →
onAuditEventcallback — wired inapp.ts. The plugin invokes it for invite/role-change/remove and organization update, and the callback forwards torecordAudit.
In-app notifications
lib/notifications.ts exposes notifyUser(services, ...) and
notifyUsers(services, ...) — the same fire-and-forget discipline as
recordAudit (Pick<AppServices, 'db'> signature, failures logged not
propagated). Wired into Better Auth's org hooks in app.ts for
invite_sent / member_role_changed / member_removed /
workspace_updated so recipients see an inbox row without the mutation
path caring about delivery.
Do NOT read notifications back to make a decision — same "observability,
not truth" rule as audit_logs. Retention is handled by the
notifications-retention worker job (see apps/worker/AGENTS.md).
Query patterns
Tenant scoping is not optional
Every route that reads business data MUST scope its DB query to
c.get('session').session.activeOrganizationId. Read the id via
requireActiveOrg(services, session) — never trust the client-side session
role/org, always re-resolve on the server. A route that forgets to add the
WHERE organization_id = ? predicate is a cross-tenant data leak, not a
performance issue. The pattern is uniform across me.ts, billing.ts, and
any new business route.
Parallelize independent lookups with Promise.all
A handler that makes 3 independent DB reads should await them together, not
sequentially. Sequential reads pay t1 + t2 + t3 of network + planner time;
parallel pays max(t1, t2, t3). See /api/me/stats in routes/me.ts for the
canonical shape.
Do NOT parallelize when a later query depends on an earlier one — the read is sequential by definition.
Dashboard stats are TENANT-scoped, not platform-scoped
/api/me/stats returns numbers about ONE workspace: what plan it is on, when
it renews, how many members it has, how much it has paid. It does NOT return
platform-wide MRR / lifetime revenue — this repository has no platform-admin
concept. The role hierarchy (owner | admin | member) is per-organization.
If you add a stat, it must be answerable from WHERE organization_id = ?.
Cross-tenant aggregations belong in a separate admin surface that this
boilerplate deliberately does not ship.
Composite indexes cover query filter + sort together
When you write a query with WHERE org = ? AND status = ? ORDER BY created_at DESC LIMIT N, verify with EXPLAIN (ANALYZE, BUFFERS) that the plan is an
Index Scan (or Index Only Scan) with no separate Sort node. If Postgres
adds a Sort, either the column order in the index does not match the query
order, or the index sort direction does not match the ORDER BY direction.
Fix the index — see packages/db/AGENTS.md "Indexing rules".
Env vars this app reads
Consumed via loadEnv() from @saas/config/env. Do not read process.env directly in any route.
DATABASE_URL,AUTH_SECRET,AUTH_TRUSTED_ORIGINS,API_URL,APP_URL,APP_NAME,NODE_ENVREDIS_URL(required at boot in production for the rate-limit store)TRUST_PROXY(boolean; enable when running behind an HTTPS proxy soX-Forwarded-Foris trusted for rate-limit keying)COLUMN_ENCRYPTION_KEY(required in production; validated at env-schema boot)RESEND_API_KEY(optional — stubs when missing),EMAIL_FROMPAYMENT_PROVIDER,PAYMENT_RETURN_URL,PAYMENT_WEBHOOK_URL, plus provider-specific keysSTORAGE_PROVIDER,STORAGE_ACCOUNT_ID,STORAGE_ACCESS_KEY_ID,STORAGE_SECRET_ACCESS_KEY,STORAGE_ENDPOINT,STORAGE_PUBLIC_BUCKET,STORAGE_PRIVATE_BUCKET,STORAGE_PUBLIC_URL(required in production)RATE_LIMIT_WINDOW_MS,RATE_LIMIT_MAXGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET(all optional)
Common tasks
bun run --filter @saas/api dev # hot reload with --env-file=../../.env
bun run --filter @saas/api typecheck
bun run --filter @saas/api test
Adding a route
- Create
src/routes/<name>.tsexporting anOpenAPIHono<AppEnv>. - Declare each endpoint with
createRoute({ ... })includingtags,summary, response schemas. - Register with
app.route('/api/<name>', <name>Router)inapp.ts. - If the route needs auth, apply
requireSession. If it mutates workspace state, applyrequireRole(services, session, 'admin'). - If it returns money, use minor units in the response — never format.
Adding an OAuth provider
- Update
AuthConfiginpackages/auth/src/server.tsand pass through insocialProviders. - Read the credentials in
buildServicesand pass intocreateAuth. - Add env vars to
@saas/config/envAND.env.example. - Add the button to
apps/web/src/components/marketing/oauth-buttons.tsx.