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)),
);
  1. declare module 'inwire' per file that registers — co-located with .add(). container.ts declares cross-cutting bindings it adds directly.
  2. Never defineModule<TDeps>() with explicit generic — fallback for forward-refs only. Pinia-style is SOTA.
  3. Di = typeof di after .build() — derived runtime shape. Don't confuse with the global inwire.AppDeps interface (typing surface for the augmentation).
  4. All deps via DI — routes consume by name (di.XxxUseCase.execute(...)); never new Xxx(...) (bypasses container, breaks per-test impl swap). No service locators.
  5. 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; ./clienthcWithType (pre-typed factory).

  • Trailing-slash normalize the baseUrlhc drops the last segment if missing.
  • AbortSignal via per-call second arg → await $get({}, { init: { signal } }).
  • Type sharing: InferRequestType<typeof $endpoint>["json"] + InferResponseType<typeof $endpoint, 200>.
  • Errors stay throw on !res.okApplyGlobalResponse widens 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()httpLoggersecureHeaders()+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-limitrequireRateLimit(deps, policy) wraps rate-limiter-flexible; policies in rate-limit.policies.ts. Fail-closed (failClosed: true) on auth-sensitive policies → 503 RATE_LIMITER_UNAVAILABLE when the store errors (a store outage must not silently disable brute-force protection — OWASP A10:2025); GLOBAL/CSP stay fail-open. IP via resolveClientIp (rate-limit.ip.ts) — OWASP rightmost-non-trusted, TRUSTED_PROXIES accepts private (PaaS edge ranges) / CIDR / exact IPs via node:net BlockList. BetterAuth built-in rateLimit is disabled — single envelope. Store postgres s'appuie sur un pool pg dédié isolé du pool app — factory getRateLimitDbClient() dans @packages/drizzle, sélectionnée via storeFactoryFor(env.RATE_LIMIT_STORE, getRateLimitDbClient) (lazy : pool créé seulement si RATE_LIMIT_STORE=postgres) — pour que l'épuisement du pool sous flood ne dégrade pas les requêtes métier.
  • CSRFrequireCsrf({ outbox, allowedOrigins }) validates the unforgeable Origin header 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 emits security.csrf.rejected (reason in the audit event only, never the client response) and throws SECURITY_CSRF_FORBIDDEN (403). /api/auth/* (BetterAuth trustedOrigins) and /internal/* (HMAC) are exempt. env.CORS_ORIGIN is the single allowlist feeding cors(), 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" }. No op, no attributes.
  • Inner span wraps only query.execute() / client.send() / fetch(): { name: query.toSQL().sql, op: "db.query", attributes: { "db.system.name": "postgresql" } } (or op: "http.client" for HTTP).
  • const exec = tx ?? db stays outside the startSpan callback (Lazar convention — pure cosmetic, but keeps the span chrono honest).
  • catch + this.instrumentation.capture(err) + return-or-rethrow: methods returning Promise<Result<T, E>> capture + return Result.fail(...); methods returning Promise<T> capture + rethrow. Never swallow.
  • Multi-query methods (e.g. executeWipe with 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 presignPUTconfirm; 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.

  1. Port = pure transport. Storage port exposes only SDK ops (presignUpload, presignDownload, headObject, deleteObject, publicUrlFor). Zero business rules.
  2. 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. No throw — return Result<T, <Domain>Error>.
  3. Validation at controller boundary in DTOs (filename regex, scope regex, size cap, max TTL), via zV (shared wrapper of @hono/zod-validator that throws HTTPException(400) on failure so the 400 doesn't pollute the response union type). Use-cases trust input.
  4. Routes = thin controllers. DI resolve → await execute(...)Result → HTTP via central statusFor(error) switch keyed off *_FORBIDDEN/*_NOT_FOUND/*_INTEGRITY_FAILED/*_PROVIDER_FAILURE (403/404/422/502).
  5. Provider-agnostic via S3 SDK config: region: "auto", forcePathStyle: true. Boot-time fail-hard if production endpoint is localhost or creds are default.
  6. Confirm mandatory: HeadObjects actual size/contentType, deletes on mismatch, returns server-verified { key, size, contentType, publicUrl }. Size permissive (actual > expected fails); content-type strict. Trusting client-declared values without confirm is the enforcement gap.
  7. Multi-step factory chain. The upload mutationOptions resolves only after confirm succeeds — 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:

  • databaseHooks for core models (user/session/account/verification) — TX-bound, captures all flows including non-HTTP. Used for USER_CREATED, USER_SIGNED_{IN,OUT}, USER_ACCOUNT_UNLINKED.
  • hooks.after + createAuthMiddleware for plugin events (twoFactor, passkey, email-verified, password-changed, link-social) — path-based, only voie viable since plugin tables aren't exposed in databaseHooks. Filter if (ctx.context.returned instanceof APIError) return is critical (otherwise events fire on 4xx).
  • hooks.before + createAuthMiddleware for pre-rejection security signals (S5a abuse-prevention: disposable-email, per-account credential-stuffing, HIBP telemetry) — emits before the throw APIError. Trap: ctx.context.request / ctx.context.session are undefined in a before-hook (it runs before the session middleware) — read the IP from ctx.headers, load the authenticated actor via auth.api.getSession({ headers: ctx.headers }). Wiring ctx.context.* throws before the emit → event silently lost + /sign-in 500s; only an end-to-end pass catches it (unit tests don't mount the hooks).
  • Native callbacksemailAndPassword.{sendResetPassword,onPasswordReset}, magicLink.sendMagicLink for 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/policies is 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/drizzle enum) sees the change at compile time. POLICY_URLS is the swap point for hosting the full policy text externally (marketing site / CMS) instead of in-app.
  • PolicyAcceptanceServiceaccept(userId, types, ipAddress?) writes N rows + emits N user.policy.accepted events (retention compliance) atomically in one uow.run TX — on any insert failure it throws to force a full rollback (no partial acceptance), then returns Result.fail. getStaleTypes(userId) drives the gate. DrizzlePolicyAcceptanceStore is fully §8-instrumented (outer + inner spans + capture); the service itself only captures in its catch (orchestration, no span).
  • requireCurrentPolicies (shared/middleware/policy.middleware.ts) — composable, not mounted globally. Throws HTTPException(409) when any policy is stale. The _shell beforeLoad redirect is the live UX gate; this middleware is defense-in-depth for future business routes.
  • Sign-up acceptance via /verify-email hookPolicyAcceptanceService.accept is called from the BetterAuth /verify-email after-hook in auth.ts (idempotent via getStaleTypes) AND from POST /me/policies/accept. Not at /sign-up/email: that route has no session yet and returns a synthetic user on duplicate-email. See docs/HISTORY.md Phase A.2 for the full deviation note.

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-consent est le SSOT (CONSENT_CATEGORIES, OPTIONAL_CATEGORIES, CONSENT_COOKIE_NAME = "cc_sid", COOKIE_CONSENT_VERSION, CONSENT_GRANT_TTL_DAYS, CONSENT_REFUSAL_TTL_DAYS). Source-only. Bump COOKIE_CONSENT_VERSION ici → tous les users re-promptés automatiquement.
  • IConsentStore port (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) (UPDATE user_id WHERE user_id IS NULL).
  • Routes /consents — publiques, optionalAuth (guests ET connectés). Cookie cc_sid httpOnly géré serveur. Rate-limit CONSENT_POST_POLICY sur 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 (Domain refusé + secure requis mais sameSite: none pour cross-origin). Même logique que le cookie de session BetterAuth.
  • Sweep guests expirés (shared/internal-routes/sweep-consents.route.ts, gate internalLayers HMAC) : purge user_id IS NULL AND expires_at < cutoff (env CONSENT_RETENTION_DAYS=365). Ajouté au runner cron/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:

  1. Rolebilling:["read","manage"] capability (@packages/access-control, pre-existing).
  2. 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).
  3. Tier/featurerequireFeature(flag) / requirePlan(minTier) middlewares (shared/middleware/billing.middleware.ts) → 402 BILLING_PAYMENT_REQUIRED.
  4. Quota (Phase B.2, dormant skeleton) — limits in ENTITLEMENTS[tier].quotas (null = unlimited). requireQuota(key, readUsage) middleware = best-effort pre-check → 429 BILLING_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's uow.run(), TOCTOU-safe. Counting: live countScopedRows (default, zero drift) or the denormalized quota_usage table + 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)

  1. Ownership at port (ScopedRepository), not route. requireOrg exposes c.var.orgId; controller builds RepoScope.org(orgId) and passes to di.XxxUseCase.execute(input, scope); requireOrgPermission({ resource: ["action"] }) still gates capabilities. Routes construct scope; repo honors it. Skipping requireOrg on a handler reading/writing rows scoped by organizationId silently accepts requests with no active org.
  2. Queries (CQRS read side) take the same RepoScope and AND-join in WHERE. Signature (input, scope: RepoScope) => Promise<...>. Promote a withScope(table, scope) helper on 2nd occurrence — sugar on top, never substitute for the parameter.
  3. Every business table from its first migration owns organizationId NOT NULL + FK organization(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.
  4. Personal org never special-cased except via isPersonalOrg(slug) (slug = personal-${orgId}, name = "Personal"). No isPersonal flag, no metadata branch. Lifecycle hooks in auth.ts — Personal can't be deleted (beforeDeleteOrganization rejects) 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.
  5. Personal self-heals at every sign-in; non-Personal auto-collapses when last member leaves. An ensurePersonalOrgFor(userId) helper runs in databaseHooks.user.create.after (signup) AND session.create.before (back-fills legacy activeOrganizationId: null users). organizationHooks.afterRemoveMember deletes empty non-Personal orgs (skipped for Personal). Never duplicate inline.
  6. Authorization is capability-based, defined once in @packages/access-control. Package exports ac, roles = { owner, admin, member }, types OrgRole/OrgPermissions, predicate authorizeRole(role, permissions, connector?). The as unknown as AccessControl cast 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"] }).
  7. Server gate: requireOrgPermission(permissions) — same OrgPermissions shape as front. Defense in depth (server enforces, route gate prevents access, UI hides).