API rules
July 10, 2026 · View on GitHub
Hono on Bun, Clean Architecture + DDD, vertical-slice modules, inwire DI, BetterAuth, Drizzle, storage, org scoping. Loaded automatically by Claude Code when working anywhere under apps/api/. Root rules (philosophy, stack, release flow) live in /CLAUDE.md. Deeper sub-CLAUDE.md inside src/modules/ and src/shared/ carry layer-specific rules.
Layout (vertical slice / modular monolith)
apps/api/src/
shared/ Cross-cutting infra (no business) — see src/shared/CLAUDE.md
middleware/ Cross-cutting Hono middlewares: auth, error, logger, org, rate-limit (factory + policies + trusted-proxy IP), csrf (Origin-allowlist)
internal-routes/ Everything that gates `/internal/*` (cron, internal callers): `internal-signature` (HMAC primitives), `internal-signature.middleware` (server verify), `private-network.middleware` (loopback/RFC1918 gate), `internal-layers` (env-driven composer of the two), `internal-fetch` (client-side signed-fetch helper).
ports/ Cross-context port interfaces
services/ Cross-context port impls (when no module owns the impl)
env.ts, logger.ts Process-level singletons
transaction.ts `type ITransaction = Transaction` — single swap-point exception
modules/<context>/ See src/modules/CLAUDE.md for layered rules
container.ts Composition root (flat at `src/`): `.add()` for cross-cutting + `.addModule()` per context, then `.build()`.
auth.ts BetterAuth singleton — **deliberate exception** to modules/ rule (config-as-code, lib owns model). Routes auto-mount via plugin (`/api/auth/*`).
auth-queries.ts Typed Drizzle data-access for the bridge — plain functions (no port/DI/aggregate; auth is not domain), `tx?` to join bridge transactions. Keeps `auth.ts` config + event-wiring only, never inline `db.*`.
client.ts, index.ts `hcWithType` factory / server entry (chained `.route()` preserves `AppType`)
Module boundary. Within a module, layers import inwards (infrastructure/ → application/ → domain/). Cross-module communication only via domain events, shared/ports/, or shared/services/. Modules NEVER import each other — not even ports. module.ts imported only by container.ts; routes only by index.ts. Re-exporting routes from module.ts re-creates the cycle module → routes → container → module (Biome flags).
Removability. trash modules/<context>/ + remove .addModule()/app.route() lines + export * in schema barrel if module owned tables. TS error-points the rest. Shared kernel always has ≥ 2 consumers OR is cross-cutting infra by nature.
CQRS
- Commands (writes): Controller → Use Case → Aggregate → Repository → EventDispatcher → Handlers
- Queries (reads): Controller → Query (direct ORM, no use case)
DI (inwire)
Pinia-style augmentation — each module declares what it ADDS, not what it consumes. container.ts chains .addModule(...); each module.ts augments global inwire.AppDeps via declare module 'inwire', then calls defineModule() (no generic). c.X resolves transparently regardless of module order. Reorder → tsc accepts. Forget a binding any module reads → tsc rejects.
// modules/<x>/module.ts
declare module "inwire" {
interface AppDeps { IFooPort: IFooPort; FooService: FooService; }
}
export const xModule = defineModule()((b) =>
b.add("IFooPort", (): IFooPort => new ConcreteFooAdapter())
.add("FooService", (c) => new FooService(c.IFooPort)),
);
declare module 'inwire'per file that registers — co-located with.add().container.tsdeclares cross-cutting bindings it adds directly.- Never
defineModule<TDeps>()with explicit generic — fallback for forward-refs only. Pinia-style is SOTA. Di = typeof diafter.build()— derived runtime shape. Don't confuse with the globalinwire.AppDepsinterface (typing surface for the augmentation).- All deps via DI — routes consume by name (
di.XxxUseCase.execute(...)); nevernew Xxx(...)(bypasses container, breaks per-test impl swap). No service locators. - Transactions managed in controllers, passed to use cases/services.
Hono RPC (end-to-end type safety)
API exports AppType; app consumes via hono/client. Routes must be chained to accumulate types — app.use/app.onError don't add to the typed schema. Paths mirror URL; method $post/$get. Don't reintroduce a registerXxx(c, app) shape — it loses chained .route() and breaks AppType accumulation.
apps/api/package.json has two subpath exports: . → AppType+server runtime; ./client → hcWithType (pre-typed factory).
- Trailing-slash normalize the
baseUrl—hcdrops the last segment if missing. AbortSignalvia per-call second arg →await $get({}, { init: { signal } }).- Type sharing:
InferRequestType<typeof $endpoint>["json"]+InferResponseType<typeof $endpoint, 200>. - Errors stay
throw on !res.ok—ApplyGlobalResponsewidens response types but no discriminated union.
Auth (BetterAuth integration, server)
Module-level singleton (apps/api/src/auth.ts) — not wrapped in port/adapter, not in DI (wrapping recopies auth.api.* and loses auth.$Infer.*). Every consumer imports auth directly. No inline db.* in auth.ts — the SQL the hooks/bridge need lives in auth-queries.ts as typed Drizzle functions (tx?-aware to join the same transaction). This is data-access separation, not DDD: no repository class, no port, no DI — auth is infra, not domain.
Server pipeline (in order, index.ts): public /csp-report (own cors + rate-limit, mounted before the globals so it keeps cross-origin CORP) → requestId() → httpLogger → secureHeaders()+cors() → sessionMiddleware (calls auth.api.getSession() once, stores user/session on context, skips /api/auth/*) → requireRateLimit(GLOBAL_POLICY) then the per-route auth-burst policies → requireCsrf on the business mutation prefixes (/me,/uploads,/settings,/admin) → app.on(["GET","POST"], "/api/auth/*", auth.handler) → business routes → app.onError(errorHandler). Protected handlers compose requireAuth. Never re-call auth.api.getSession() per handler.
Security middleware (Phase C.1, all in shared/middleware/):
- Rate-limit —
requireRateLimit(deps, policy)wrapsrate-limiter-flexible; policies inrate-limit.policies.ts. Fail-closed (failClosed: true) on auth-sensitive policies → 503RATE_LIMITER_UNAVAILABLEwhen the store errors (a store outage must not silently disable brute-force protection — OWASP A10:2025); GLOBAL/CSP stay fail-open. IP viaresolveClientIp(rate-limit.ip.ts) — OWASP rightmost-non-trusted,TRUSTED_PROXIESacceptsprivate(PaaS edge ranges) / CIDR / exact IPs vianode:netBlockList. BetterAuth built-inrateLimitis disabled — single envelope. Storepostgress'appuie sur un pool pg dédié isolé du pool app — factorygetRateLimitDbClient()dans@packages/drizzle, sélectionnée viastoreFactoryFor(env.RATE_LIMIT_STORE, getRateLimitDbClient)(lazy : pool créé seulement siRATE_LIMIT_STORE=postgres) — pour que l'épuisement du pool sous flood ne dégrade pas les requêtes métier. - CSRF —
requireCsrf({ outbox, allowedOrigins })validates the unforgeableOriginheader against the CORS allowlist on unsafe methods (Origin-based, not double-submit token — stateless, no cookie/endpoint/front; the cross-origin deploy makes a readable double-submit cookie impossible). Bearer-authed requests skip (no ambient cookie → no CSRF). Rejection emitssecurity.csrf.rejected(reason in the audit event only, never the client response) and throwsSECURITY_CSRF_FORBIDDEN(403)./api/auth/*(BetterAuthtrustedOrigins) and/internal/*(HMAC) are exempt.env.CORS_ORIGINis the single allowlist feedingcors(),requireCsrf, and BetterAuth.
Defaults: session.cookieCache: { enabled: true, maxAge: 5*60 } (signature-only auth check between refreshes; DB is truth at expiry → instant revoke; keep maxAge ≤ 15 min). bearer() plugin alongside cookies — web cookie-based (httpOnly, XSS-safe), Capacitor uses bearer with secure storage. Cookies: httpOnly, secure: isProd, sameSite: isProd ? "none" : "lax" — none in prod because the SPA and API are served from distinct origins (decoupled deploy), so the session cookie must ride cross-site fetch; CSRF is then covered in-app by requireCsrf (Origin allowlist), not by SameSite. If a clone deploys app+api same-site (shared eTLD+1), switch to "lax" for a free transport-layer CSRF layer.
Email URLs route through the app, not the API — ${env.APP_URL}/<route>?token=... (opaque tokens) or ${env.APP_URL}/<route>/<id> (ID-based). Why: branded UX; avoids Outlook/Gmail re-autolinking visible URL text and mangling ?callbackURL=.... Don't pass redirectTo/callbackURL to auth-client methods when a send* server hook already builds the URL — duplicate dead code can silently override the canonical URL.
Don't re-implement auth.api.organization.* server-side or attach requireOrgPermission to plugin endpoints — the plugin owns role checks for /api/auth/organization/*. Custom guards apply to our business routes only.
Logging & error handling
No console.* in production paths — all logs through pino (JSON stdout in prod, pino-pretty in dev). HTTP: hono-pino with referRequestIdKey: "requestId"; status-driven (5xx→error, 4xx→warn, 2xx/3xx→info).
One app.onError(...), no per-route try/catch: error middleware is a factory createErrorHandler(instrumentation) called once in index.ts after di.build() → app.onError(createErrorHandler(di.IInstrumentation)). Why factory: middlewares importing di directly from container.ts would silently risk a runtime cycle if any module ever imported back into shared/middleware/ — the factory takes the dep as a parameter and stays cycle-immune. Envelope: HTTPException → { error: { code: "HTTP_<status>", message, requestId } } (logged at error only when status >= 500). Else → 500 { error: { code: "INTERNAL_ERROR", message: "Internal Server Error", requestId, stack? } } (stack only outside production).
Throwing the right exception is the API. Domain & application use Result<T, E> (no throw); controller translates failures → HTTPException(<status>, { message }). Envelope above is the API contract — never invent custom per route.
Observability (IInstrumentation — Phase 0.4)
Single port, DI everywhere. IInstrumentation (shared/ports/instrumentation.port.ts) combines startSpan + capture + addBreadcrumb — one interface, one constructor injection. Default impl NoOpInstrumentation; Sentry impl SentryInstrumentation swaps in when env.SENTRY_DSN is set (binding in container.ts). No module-level singleton, no service-locator — every I/O-bound class (repos, S3, Resend) receives IInstrumentation via constructor exactly like IEmailService / IStorageService. Sentry SDK init is the one exception: side-effect import "./shared/services/sentry-init" as the first import of index.ts (must hook async-hooks before pino/Hono/Drizzle attach).
Repo / service instrumentation pattern (Lazar-inspired, see docs/OBSERVABILITY.md):
- Outer span wraps the method body:
{ name: "ClassName > methodName" }. Noop, no attributes. - Inner span wraps only
query.execute()/client.send()/fetch():{ name: query.toSQL().sql, op: "db.query", attributes: { "db.system.name": "postgresql" } }(orop: "http.client"for HTTP). const exec = tx ?? dbstays outside thestartSpancallback (Lazar convention — pure cosmetic, but keeps the span chrono honest).- catch +
this.instrumentation.capture(err)+ return-or-rethrow: methods returningPromise<Result<T, E>>capture +return Result.fail(...); methods returningPromise<T>capture + rethrow. Never swallow. - Multi-query methods (e.g.
executeWipewith 7+ statements): outer span only — inner spans become noise. - Don't call sibling-repo methods that themselves open spans from inside a span — inline the query instead, otherwise the inner spans become orphaned siblings rather than children in Sentry's trace tree.
OTel + Prometheus /metrics are deferred to Phase D.1 (no consumer yet). The spans already shipped via IInstrumentation.startSpan will be reused when SENTRY_TRACES_SAMPLE_RATE > 0 lands.
Storage (object-storage-agnostic, S3-compatible)
Server is blind during upload — client PUTs directly to provider via presigned URL; API only sees presign and confirm. Three-step presign→PUT→confirm; symmetric download. Why three steps: providers like R2 don't support Presigned POST policies (no content-length-range, verified 2026). Signed Content-Length/Content-Type block naïve clients but providers don't verify the body — confirm (server HeadObject+DeleteObject on mismatch) is the real enforcement. Don't add a Presigned POST flow.
- Port = pure transport. Storage port exposes only SDK ops (
presignUpload,presignDownload,headObject,deleteObject,publicUrlFor). Zero business rules. - Use-cases enforce owner-scoped key
<userId>/<scope>/<uuid>-<filename>; download+confirm reject keys without the requester's<userId>/prefix (*_FORBIDDEN). Skipping this lets any authenticated user presign a GET / verify any key. Nothrow— returnResult<T, <Domain>Error>. - Validation at controller boundary in DTOs (filename regex, scope regex, size cap, max TTL), via
zV(shared wrapper of@hono/zod-validatorthat throwsHTTPException(400)on failure so the 400 doesn't pollute the response union type). Use-cases trust input. - Routes = thin controllers. DI resolve →
await execute(...)→Result→ HTTP via centralstatusFor(error)switch keyed off*_FORBIDDEN/*_NOT_FOUND/*_INTEGRITY_FAILED/*_PROVIDER_FAILURE(403/404/422/502). - Provider-agnostic via S3 SDK config:
region: "auto",forcePathStyle: true. Boot-time fail-hard if production endpoint is localhost or creds are default. - Confirm mandatory:
HeadObjects actual size/contentType, deletes on mismatch, returns server-verified{ key, size, contentType, publicUrl }. Size permissive (actual > expectedfails); content-type strict. Trusting client-declared values withoutconfirmis the enforcement gap. - Multi-step factory chain. The upload
mutationOptionsresolves only afterconfirmsucceeds — UI never sees "maybe uploaded".
Phase 2 (deferred until first concrete consumer): orphan GC; integration event bus (IAppEventBus, distinct from domain events) when 2+ handlers need an upload-confirmed event.
Events (transactional outbox)
IUnitOfWork.run(cb) opens an EventCollector (AsyncLocalStorage). repo.save(agg, tx) wraps trackEventsOnSuccess(result, agg) to push pulled domain events into the collector. Pre-COMMIT, the UoW flushes them via outbox.enqueue in the same TX → atomicity. Post-COMMIT, Postgres pg_notify wakes OutboxDispatcher which fans out to built-in subscribers (audit, webhook fanout) inside the dispatch TX, then to user-defined onEvent(...) handlers post-commit (best-effort, isolated).
BetterAuth → outbox bridge lives in auth.ts (the documented exception). These paths:
databaseHooksfor core models (user/session/account/verification) — TX-bound, captures all flows including non-HTTP. Used forUSER_CREATED,USER_SIGNED_{IN,OUT},USER_ACCOUNT_UNLINKED.hooks.after+createAuthMiddlewarefor plugin events (twoFactor, passkey, email-verified, password-changed, link-social) — path-based, only voie viable since plugin tables aren't exposed indatabaseHooks. Filterif (ctx.context.returned instanceof APIError) returnis critical (otherwise events fire on 4xx).hooks.before+createAuthMiddlewarefor pre-rejection security signals (S5a abuse-prevention: disposable-email, per-account credential-stuffing, HIBP telemetry) — emits before thethrow APIError. Trap:ctx.context.request/ctx.context.sessionareundefinedin a before-hook (it runs before the session middleware) — read the IP fromctx.headers, load the authenticated actor viaauth.api.getSession({ headers: ctx.headers }). Wiringctx.context.*throws before the emit → event silently lost +/sign-in500s; only an end-to-end pass catches it (unit tests don't mount the hooks).- Native callbacks —
emailAndPassword.{sendResetPassword,onPasswordReset},magicLink.sendMagicLinkfor the corresponding events.
organizationHooks (org plugin) covers all org/member/invitation events.
Hard rules: uow.run() cannot be nested (Drizzle nested TX = independent, not savepoints — guarded by EventCollector.hasContext() throw). addEvent outside uow.run() = events lost (dev-mode warning logged via EventCollector.setOutOfContextLogger).
Retention: derived pipeline tables (outbox_event, audit_log, webhook_delivery) grow unbounded — purged by HMAC-gated /internal/sweep-* routes (shared/internal-routes/sweep-*.route.ts), driven by env knobs OUTBOX_RETENTION_DAYS / AUDIT_LOG_{OPERATIONAL,COMPLIANCE}_RETENTION_DAYS / WEBHOOK_DELIVERY_RETENTION_DAYS. Triggered by external cron in strict order (FK ON DELETE RESTRICT): webhook → audit → outbox. The sweep itself emits no event (rule §6 exception — see /CLAUDE.md).
See docs/EVENTS.md for full spec, retention matrix, and cron recipe.
Policy versioning (modules/policies/ — Phase A.2)
Compliance infra, not DDD. Records which policy version each user accepted and when. Mirrors the audit-log module shape.
@packages/policiesis the SSOT (POLICY_VERSIONS,POLICY_TYPES,POLICY_CHANGELOG,POLICY_URLS). Source-only package. Bump the version string here → every consumer (API gate, front sign-up,@packages/drizzleenum) sees the change at compile time.POLICY_URLSis the swap point for hosting the full policy text externally (marketing site / CMS) instead of in-app.PolicyAcceptanceService—accept(userId, types, ipAddress?)writes N rows + emits Nuser.policy.acceptedevents (retentioncompliance) atomically in oneuow.runTX — on any insert failure it throws to force a full rollback (no partial acceptance), then returnsResult.fail.getStaleTypes(userId)drives the gate.DrizzlePolicyAcceptanceStoreis fully §8-instrumented (outer + inner spans + capture); the service itself onlycaptures in its catch (orchestration, no span).requireCurrentPolicies(shared/middleware/policy.middleware.ts) — composable, not mounted globally. ThrowsHTTPException(409)when any policy is stale. The_shellbeforeLoadredirect is the live UX gate; this middleware is defense-in-depth for future business routes.- Sign-up acceptance via
/verify-emailhook —PolicyAcceptanceService.acceptis called from the BetterAuth/verify-emailafter-hook inauth.ts(idempotent viagetStaleTypes) AND fromPOST /me/policies/accept. Not at/sign-up/email: that route has no session yet and returns a synthetic user on duplicate-email. Seedocs/HISTORY.mdPhase A.2 for the full deviation note.
Cookie consent (modules/consents/ — Phase A.4)
Compliance infra, pas DDD. Enregistre le consentement device-scoped (guest→user réconcilié au login). Miroir du module modules/policies/ — même shape (port + service + drizzle store + routes).
@packages/cookie-consentest le SSOT (CONSENT_CATEGORIES,OPTIONAL_CATEGORIES,CONSENT_COOKIE_NAME = "cc_sid",COOKIE_CONSENT_VERSION,CONSENT_GRANT_TTL_DAYS,CONSENT_REFUSAL_TTL_DAYS). Source-only. BumpCOOKIE_CONSENT_VERSIONici → tous les users re-promptés automatiquement.IConsentStoreport (module-private) +DrizzleConsentStore— §8-instrumenté.ConsentService:record(append-only — chaque save = nouveau row, le plus récent gagne) ·withdraw·getActive(avec fallback subjectId quand un user connecté n'a pas encore de record) ·reconcile(subjectId, userId)(UPDATEuser_id WHERE user_id IS NULL).- Routes
/consents— publiques,optionalAuth(guests ET connectés). Cookiecc_sidhttpOnly géré serveur. Rate-limitCONSENT_POST_POLICYsur POST/DELETE uniquement — GET exempt (appelé en prefetch à chaque render ; un GET rate-limité sature la fenêtre en quelques reloads normaux et bloque l'affichage du banner). CSRF Origin sur/consents(comme tous les prefixes de mutation). - Cookie
cc_sid:httpOnly: true,secure: isProd,sameSite: isProd ? "none" : "lax",path: "/". Pas de prefix__Host-— le déploiement cross-origin (SPA + API origines distinctes) rend__Host-inutilisable (Domainrefusé +securerequis maissameSite: nonepour cross-origin). Même logique que le cookie de session BetterAuth. - Sweep guests expirés (
shared/internal-routes/sweep-consents.route.ts, gateinternalLayersHMAC) : purgeuser_id IS NULL AND expires_at < cutoff(envCONSENT_RETENTION_DAYS=365). Ajouté au runnercron/sweep.ts.
Réconciliation au login — règle réutilisable :
Pour exécuter du code à chaque login (tous flux confondus : password/passkey/magic-link/2FA/email-verify/OAuth futur) avec accès aux cookies de requête, utiliser hooks.after + createAuthMiddleware + vérifier ctx.context.newSession. Ne pas utiliser databaseHooks.session.create — ce hook n'a pas accès aux Request headers (donc pas aux cookies).
// Dans auth.ts — réconciliation consent au login
hooks: {
after: createAuthMiddleware(async (ctx) => {
const userId = ctx.context.newSession?.user?.id; // null hors login → skip
if (!userId) return;
const subjectId = readCookieFromHeaders(ctx.headers, CONSENT_COOKIE_NAME);
if (!subjectId) return;
await di.ConsentService.reconcile(subjectId, userId);
}),
}
ctx.context.newSession est positionné par BetterAuth sur chaque login (tous flux) et vaut null sur les requêtes courantes de session. C'est le signal idiomatique pour "un login vient d'avoir lieu sur cette requête". Câbler sur databaseHooks.session.create rate les cookies ; câbler sur un path spécifique (ex. /sign-in/email) rate les autres flux.
Billing (modules/billing/ + stripe() plugin — Phase B.1)
Pragmatic infra, NOT DDD. No domain layer: config.ts holds ENTITLEMENTS[tier] (features / rank / maxMembers, null = unlimited seats), @better-auth/stripe plugin owns subscription STATE (its subscription table, webhook-synced via /api/auth/stripe/webhook), Stripe owns price/display (metadata.tier join key + marketing_features). Typed config is the single business-rules SSOT — never duplicate into a domain model.
Four orthogonal gate axes — applied independently, never conflated:
- Role —
billing:["read","manage"]capability (@packages/access-control, pre-existing). - Seats — hard-capped in the org plugin's
beforeAddMember+beforeAcceptInvitation+beforeCreateInvitation. All three hooks must be wired (§6 two-path trap — missing one silently admits overquota members). - Tier/feature —
requireFeature(flag)/requirePlan(minTier)middlewares (shared/middleware/billing.middleware.ts) → 402BILLING_PAYMENT_REQUIRED. - Quota (Phase B.2, dormant skeleton) — limits in
ENTITLEMENTS[tier].quotas(null= unlimited).requireQuota(key, readUsage)middleware = best-effort pre-check → 429BILLING_QUOTA_EXCEEDED;reserveQuota(tx, orgId, key, limit, countFn)(shared/db/quota-reservation.ts) = the authoritative gate —pg_advisory_xact_lock+ count + assert inside the write'suow.run(), TOCTOU-safe. Counting: livecountScopedRows(default, zero drift) or the denormalizedquota_usagetable +modules/quotas/IQuotaUsageStore.{increment,current,reset}(high-volume, increment in the SAME TX as the gated write — never background). Dormant + knip-whitelisted until a resource is wired. Details:docs/QUOTA-GATING.md.
No billing backoffice: Stripe Checkout + Billing Portal hosted. POST /billing/portal gated requireOrgPermission({ billing:["manage"] }).
Events: billing.subscription.{created,updated,cancelled} + billing.payment.failed, emitted from stripe plugin callbacks in auth.ts (same BetterAuth bridge pattern as org/policy hooks). billing.quota.exceeded (operational) is emitted from requireQuota only — reserveQuota does not emit; a caller enforcing via reserveQuota alone emits it themselves.
Organization scoping (server)
- Ownership at port (
ScopedRepository), not route.requireOrgexposesc.var.orgId; controller buildsRepoScope.org(orgId)and passes todi.XxxUseCase.execute(input, scope);requireOrgPermission({ resource: ["action"] })still gates capabilities. Routes construct scope; repo honors it. SkippingrequireOrgon a handler reading/writing rows scoped byorganizationIdsilently accepts requests with no active org. - Queries (CQRS read side) take the same
RepoScopeand AND-join inWHERE. Signature(input, scope: RepoScope) => Promise<...>. Promote awithScope(table, scope)helper on 2nd occurrence — sugar on top, never substitute for the parameter. - Every business table from its first migration owns
organizationId NOT NULL+ FKorganization(id) ON DELETE CASCADE. Why: post-hoc multi-tenancy (backfill+orphan handling+query rewrite) is the most expensive class of refactor. Never skip — even solo-product today. - Personal org never special-cased except via
isPersonalOrg(slug)(slug = personal-${orgId},name = "Personal"). NoisPersonalflag, no metadata branch. Lifecycle hooks inauth.ts— Personal can't be deleted (beforeDeleteOrganizationrejects) or left; removal goes via account deletion (cascades). Why: Personal is auto-created on signup tied 1:1 to user — standalone deletion would orphan them. - Personal self-heals at every sign-in; non-Personal auto-collapses when last member leaves. An
ensurePersonalOrgFor(userId)helper runs indatabaseHooks.user.create.after(signup) ANDsession.create.before(back-fills legacyactiveOrganizationId: nullusers).organizationHooks.afterRemoveMemberdeletes empty non-Personal orgs (skipped for Personal). Never duplicate inline. - Authorization is capability-based, defined once in
@packages/access-control. Package exportsac,roles = { owner, admin, member }, typesOrgRole/OrgPermissions, predicateauthorizeRole(role, permissions, connector?). Theas unknown as AccessControlcast required by BetterAuth's generic plugin signature stays inside the package. Why: roles+statements duplicated front/back is the most common drift in multi-tenant SaaS. Never hardcode role tuples — describe the capability ({ resource: ["action"] }). - Server gate:
requireOrgPermission(permissions)— sameOrgPermissionsshape as front. Defense in depth (server enforces, route gate prevents access, UI hides).