Saltar al contenido principal

ADR-017 — Three-surface architecture (console · tenant app · admin retirement)

:::note Contenido en inglés Esta página del wiki se sincroniza desde la base de conocimiento en inglés y todavía no está traducida. :::

canonical · tech-lead · updated 2026-09-06 · source

  • Status: Accepted — amended 2026-08-31 (José): one domain per tenant, role-gated operator access — the separate operator host (primary admin.<tenant-domain> / fallback <tenant>.operador.tuempresa.digital) is retired. See § Amendment (2026-08-31). Superseded in part by ADR-018 (2026-09-06, Phase 1 of 3: staff/operator auth on apps/admin, apps/console, and apps/chat moves to better-auth — § 2 "operators authenticate with Clerk" and the Clerk half of § Auth design no longer hold for those three apps. apps/portal's /operator/* surface (the actual "operator" this section describes) stays on Clerk until Phase 3 (#1538); the three-surface topology, the path-gated operator surface, and the route-group/cookie-namespace split stand throughout.
  • Date: 2026-07-10 · amended 2026-08-31
  • Owner: Tech Lead
  • Related issues: effort #1257 (this decision's effort) · #1258 (this ADR) · #1259 (auth spike + base-app decision, recorded in its comment) · #1261 (console absorbs the platform level) · effort #1262 (comprender → portal migration) · supersedes effort #1253 (closed — admin URL work has no future on a frozen surface) · #1428 (the admin.<tenant> TLS bug that triggered the amendment) · #1435 (role-gated operator surface on the tenant domain) · effort #1438 (shipped the amendment: path-gated operator surface + operador.* 308) · #1414 (host-resolution work made moot by the amendment) · effort #1444 (client-owned custom domains administrable from the console — brings them IN scope)

Context

  • The 2026-07-09/10 QA pass found FOUR shipped surfaces with overlapping operator functionality: apps/admin (Clerk, platform + project operator screens), apps/console (superadmin internals), apps/clients/comprender (white-label operator app, Clerk, host-resolved), and apps/portal (multi-tenant student app, better-auth, host-resolved — ADR-016). Comprender had re-implemented most of admin's operator screens; both drift independently.
  • José's decision (2026-07-09/10 QA pass): collapse to three surfaces with one owner each — a superadmin config surface, ONE white-label tenant app, and a staged retirement of admin.
  • Forces: one deploy per population, no duplicated operator screens, per-tenant custom domains (Host resolution, ADR-016), the thin-app architecture (ADR-008) that makes app shells cheap, and the import boundaries of ADR-012 (shared logic flows through @tedos/core / @tedos/ui, never app→app).

Decision

1. apps/console = the superadmin tenant-config surface

The console (Amber/ink, console.tuempresa.digital) is where WE configure tenants: module enablement per tenant, connections, limits — plus the platform level it absorbs from admin (projects list + creation, tenant config screens, the Plan-Engine builder stack) — sub #1261.

2. ONE multi-tenant white-label tenant app — operators AND students

  • A single Host-resolved app on ONE domain per tenant (<tenant>.tuempresa.digital) serves both populations with role-split layouts (operator chrome vs student chrome — route groups, not separate apps). Access is differentiated IN THE APP by role, never by host (amended 2026-08-31 — see § Amendment).
  • Fully separate auth stacks: operators authenticate with Clerk; students with better-auth phone OTP (ADR-016). Never shared sessions — two populations, two identity models, zero cookie/session overlap. Coexistence design: § Auth design (spike #1259) below.

3. apps/portal is the BASE of the tenant app

Decided from the standards audit (evidence in the #1259 comment, 2026-07-10):

  • Portal wins the load-bearing standards: native ADR-016 multi-tenancy end-to-end (Host resolution, runtime branding, per-tenant auth cookie prefix), React.cache on 15/15 data-fns (comprender 0/10), client-only-on-leaves RSC, type-* typography (145 uses vs 62 raw text-* in comprender), and active momentum (store/cart/course interior).
  • Comprender's assets migrate INTO portal (effort #1262): the dual-rail ClientShell chrome (promoted to @tedos/ui in transit), module/feature/tier gating (enabledModules + requireModule/requireFeature over @tedos/core/modules), and the 10 operator screens as portal shells (data-fns wrapped in React.cache, raw text-* converted to type-* in transit). Comprender then retires. Its brand hardcoding, env-fixture tenant role, and missing caching are why it is not the base.

4. apps/admin is DEPRECATED — frozen now, staged retirement

  • Freeze rule (hard, effective immediately): nothing new lands in apps/admin — no new screens, seams, tokens, or fixes beyond keeping the build green. A PR adding net-new admin functionality is a review blocker.
  • Staged retirement: admin is deleted once the console absorbs the platform level (#1261). The unique salvage (operator seams that exist ONLY in admin: credentials, student-binds, video-connections, lessons-actions; the projects seam + builder stack) is inventoried in #1257's body — everything else is dead weight and deletes with the app.

Scope boundaries

  • This ADR fixes the surface topology + auth-stack split. Screen design (Designer), the migration order inside #1262, and the console screens' shape (#1261) are their efforts' calls.
  • It does not change ADR-016's student-auth decision — it extends where that portal grows.

Consequences

Positive

  • One tenant app = one deploy, one screen implementation per operator surface; the admin/comprender drift class of QA findings disappears structurally.
  • QA-finding fixes land in their final home, not the frozen one: operator-screen fixes → the tenant app (portal, post-#1262); tenant provisioning/config fixes → console (#1261); student-flow fixes → portal as today. Admin gets no fixes beyond build-green.
  • Portal's standards (ADR-016 multi-tenancy, React.cache, RSC discipline, type-*) become the floor for every migrated screen — the migration is also a quality pass.

Negative / trade-offs

  • Two auth stacks in ONE app is real complexity (route-group isolation, cookie namespaces, proxy ordering) — accepted deliberately; the design is the § below and José ratifies it.
  • Until #1261/#1262 land, admin remains deployed-but-frozen — a window where the retired surface still serves the platform level. Kept short by sequencing both efforts next.
  • Migrating 10 operator screens is churn comprender already paid once — the audit says paying it again into the standards-compliant base is cheaper than backfilling portal's standards into comprender.

Follow-ups required

  • #1261 — console absorbs the platform level (projects seam, tenant config, builder stack home).
  • Effort #1262 — comprender assets migrate into portal; comprender retires.
  • #1257 scope 3 — salvage the 6 admin-only operator seams into the tenant surface.
  • Admin freeze note in admin's README + the .claude rules (same effort #1257).
  • José ratifies the § Auth design (spike #1259) proposal below.

Alternatives considered

OptionWhy not
Comprender as the tenant-app baseFails the standards audit: 0/10 React.cache data-fns, brand hardcoding, env-fixture tenancy, 62 raw text-*; backfilling costs more than migrating (#1259 comment).
Two tenant apps (operator + student) foreverTwo deploys + two Host-resolution stacks per client domain, duplicated chrome/gating, and a cross-app auth story anyway — the QA pass showed the duplication cost live.
Keep admin as the operator surfaceAdmin is not white-label (Cian skin, no Host resolution) and duplicates the tenant app screen-for-screen; freezing it is what removes the drift.
Big-bang admin deletion nowThe platform level (projects, config, builder) has no other home until #1261 — staged retirement keeps the lights on without accepting new drift.

References

  • Effort #1257 (scope + salvage inventory) · #1259 comment (base-app evidence, 2026-07-10)
  • ADR-016 — student auth (better-auth) + apps/portal
  • ADR-012 — no app→app imports; shared logic via @tedos/core, generic UI via @tedos/ui
  • ADR-008 — thin-app architecture (shells over packages)
  • .claude/rules/color-scope.md — console = Amber/ink; the tenant app skins per tenant
  • decisions/log.md (2026-07-10)

Amendment (2026-08-31) — one domain per tenant, role-gated operator access

Decided by José 2026-08-31 (#1428). Supersedes the original operator-entry scheme below (primary admin.<tenant-domain> subdomain, fallback <tenant>.operador.tuempresa.digital).

The decision

  • ONE domain per tenant: <tenant>.tuempresa.digital. No separate operator host — admin.<tenant-domain> and <tenant>.operador.tuempresa.digital are both retired as patterns.
  • Access is differentiated IN THE APP by role, not by host: operators authenticate with Clerk and reach operator screens via role gating; students/public use better-auth on the same domain.
  • Client-owned custom domains are IN scope (effort #1444, 2026-08-31). A tenant's store/student surface may live on the client's OWN domain (e.g. tienda.comprender.mx): the domain is stored as project.domain (UNIQUE, #1446), added to the portal's Vercel project and DNS-verified from the console (Configurar → Dominios, over the #922/#930 provisioning API), and then Host-resolved by the SAME multi-tenant portal deployment like any <tenant>.tuempresa.digital host — no per-tenant build. Role gating is unchanged: the operator surface stays path-gated on whichever host serves the tenant. Flow + env: cd-and-domain-provisioning.md.
  • console.tuempresa.digital is unchanged — José-only superadmin, not a tenant surface.

Rationale

  • Vercel wildcard certs cover ONE label: admin.<tenant>.tuempresa.digital is a second-level subdomain with no TLS under *.tuempresa.digital — the #1428 bug.
  • Clerk satellite-domain pricing was evaluated and rejected.
  • One host removes the whole host-resolution problem — #1414's premise disappears.

Migration (#1435) — SHIPPED (effort #1438, 2026-08-31)

The role-gated operator surface is live on the tenant domain: proxy.ts gates /operator/* (+ Clerk internals) by PATH with the same Clerk session check, and the API keeps authorizing org → projectId → role per request. The retired hosts — admin.<tenant-domain> and <tenant>.operador.tuempresa.digital — now answer a permanent 308 redirect to the same path + query on https://<tenant>.tuempresa.digital at the proxy edge (apps/portal/src/lib/operator-host.ts holds only that mapping). The host-detection code is removed: isOperatorHost, apexHostFromOperatorHost, the PORTAL_OPERATOR_HOST override, the #1412 layout exemption (every served path now resolves its tenant), and the proxy apex→operator bounce that built dead admin.<host> URLs. The root layout keys the neutral operator chrome off a proxy-set surface marker (apps/portal/src/lib/surface.ts) instead of the Host.

Auth design (spike #1259)

Accepted (José, 2026-07-10); operator entry AMENDED 2026-08-31 — the original admin.<tenant-domain> subdomain entry is retired; operator access is role-gated on the single tenant domain (§ Amendment above). The route-group split and cookie-namespace design below stand. How Clerk (operator) and better-auth phone OTP (student) coexist in ONE Next.js app (apps/portal) with zero session sharing.

Route-group split

  • Two root-level route groups, each with its own layout, chrome, and auth provider: (student) (today's (portal) group — courses, cart, invoices, certificates) and (operator) (the migrated comprender/admin operator screens).
  • ClerkProvider mounts ONLY inside the (operator) layout — Clerk JS never ships in the student bundle. The (student) layout keeps ADR-016's getSession() as its authoritative check; the (operator) layout uses Clerk's auth(). Neither layout ever reads the other stack's cookie — that is the zero-sharing invariant, enforced by construction.
  • Student (better-auth): already tenant-namespaced — the API scopes each project's instance via advanced.cookiePrefix (ADR-016 security finding #2), so the session cookie is <tenant-prefix>.session_token (__Secure- over https), host-only (no Domain attribute) on the tenant apex host.
  • Operator (Clerk): Clerk's __session / __client_uat cookies live host-only on the tenant domain alongside the student jar — distinct cookie names, never read across stacks (amended 2026-08-31; was: operator-entry host only). Neither stack ever sets a wide Domain=.<tenant-domain> cookie — host-only on both sides is the rule.

proxy.ts ordering

  • ONE proxy on the single tenant host, branching on path (amended 2026-08-31; was: Host first, then path):
    1. Operator paths → the Clerk gate (clerkMiddleware-style protection wraps only this branch); unauthenticated → Clerk sign-in; authenticated → role gating in the (operator) layout.
    2. Student/public paths → the existing optimistic better-auth gate (cookie-suffix presence check, apps/portal/src/proxy.ts), public paths unchanged (/ingresar, /api/auth, /verificar, /terminos).
  • Gates stay optimistic in the proxy (cookie presence, no network call — Next guidance); the authoritative per-stack validation stays in each route group's layout.

Operator entry — single tenant domain, role-gated (amended 2026-08-31)

The spike's original subdomain recommendation (admin.<tenant-domain>, fallback <tenant>.operador.tuempresa.digital) is retired — see § Amendment (2026-08-31) for the decision, rationale (the #1428 wildcard-TLS gap, Clerk satellite pricing), and the #1435 migration. Operators enter on <tenant>.tuempresa.digital; the (operator) route group's Clerk auth + role gating is the boundary. Hard origin isolation is traded away deliberately: the zero-sharing invariant is carried by the cookie-namespace + route-group design above (a code property, not a browser origin property).