Rental Portal App Split — Design

Moves the authenticated rental portal (landlord + tenant) out of the Panel SolidStart app into its own independently deployed app, so the two products can ship on separate schedules. Ten reviewed decisions cover error-handler decoupling, the host/cookie constraint, Vitest project enumeration, sign-in and landing behaviour, a usage-derived translation split, two-app E2E and deploy pipelines, and a scoped rename inventory.

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

Summary

The authenticated rental portal moves out of the Panel app into its own SolidStart app, so the two products stop sharing a deploy. Ten reviewed decisions replace the original design's optimistic assumptions with measured constraints — most consequentially, the portal's host is pinned by where the session cookie actually lives, and the cross-host proof is an open spike rather than a formality.

  • Scope Frontend, plus test and deploy pipeline configuration
  • Not in scope Backend implementation, database, Better Auth schema
  • Delivery Two PRs, gated by an HTTPS second-host spike
  • Records ADR-0063 · amends ADR-0056

Goal, and why the split is justified

Rental and Panel are two products sold separately. Rental must deploy on its own schedule without requiring a Panel deploy. The only product overlap is Panel's internal platform-admin screen for reviewing landlord listings. Today both live in frontend/solidstart/apps/panel.

This is not speculative flexibility. The existing code already treats rental as separate; only the folder layout disagrees:

Evidence in the current treeWhat it shows
Five Panel-local import roots from the rental tree: ~/paraglide/messages, ~/paraglide/runtime, ~/lib/api/error-handlers, ~/lib/create-reactive-translator, ~/app A thin, enumerable seam — not a tangle
app.tsx carries three branches whose only purpose is to escape Panel: isOwnerRoute() and isTenantSignUpRoute() bypass AuthGate; isTenantRoute() bypasses MainLayout Panel is already working around rental's presence
routes/api/rental/[...path].ts states it directly: "rental is orgless — no x-organization-id injection" Rental sits outside the org model the Panel is built on
838 of Panel's en.json keys are rental_* Nearly half the translation surface belongs to the other product
cycle-planner and clawui already exist as sibling apps The pattern is established in this workspace

Consequences of the status quo: a rental customer's bundle ships Panel's HR, fieldforce, digital-worker and AI admin code, and a Panel deploy can affect a paying rental customer.

Both apps stay clients of one Go backend; only the frontend deploy unit splits
Both apps stay clients of one Go backend; only the frontend deploy unit splits.

Rejected alternatives

A shared packages/rental-core for DTOs and API clients

Rejected — on sizing and fit, not on coupling being unavoidable.

A well-run shared package can be versioned without forcing consumer redeploys; the objection is that the ceremony exceeds the contents here. Panel's admin surface is eight items and is Panel-owned, not a surface both apps use — proof access is admin-only and the portal never calls it. What the two apps genuinely have in common is narrower: ListingStatus values and some RentalListing field names, and they read different subsets of that type, so one shared type tends toward the union of both. Version churn also recurs; the duplication it avoids does not.

On drift: a package would prevent frontend-vs-frontend drift, for which there is no demonstrated instance here — which is not the same as proving it cannot occur — while doing nothing about frontend-vs-Go drift, the acknowledged risk. These types are hand-written, not generated. PR #75 built wire-contract drift tests for this and was reverted. Worth revisiting if the shared surface grows substantially.

Splitting the Go rental module into a second service

Rejected — the existing route groups are already the right granularity.

internal/modules/rental/module.go separates the three audiences by route group and role gate: /api/public/rental/* anonymous, /api/rental/* session-required with RequireLandlord() on properties and listings, and /api/rental/admin/* behind RequireJWTRole(admin, superadmin). Two services would share one database (a distributed monolith), duplicate Better Auth and JWKS handling, and wrap three handlers in a deployment unit.

Noted but not acted on: because admin routes sit under the /api/rental prefix, the admin API cannot be isolated by path at a gateway. The role middleware is the security boundary. Renaming to /api/admin/rental/* stays available if network-level separation is ever needed.

The ten decisions

D1 — Error handling has no AI hook and no injection seam

installAIAPIErrorHandler (error-handlers.ts:34) patches globalThis.fetch and routes every non-ok response; app.tsx:111 installs it at Panel startup. The routeAIError call inside parseApiError (line 26) therefore duplicates routing that already happens globally. The rental tree calls throwIfApiError(res) single-argument only; everything else imports just ApiError. An optional onError? parameter would have no caller.

Effect: PR1 leaves error-handlers.ts already free of the AI import and of the installer, so PR2 copies it to the portal unchanged — there is no second removal step, and no caller signature changes.

D2 — The portal is hosted under the existing auth cookie registrable domain

The browser must be able to send the session cookie to the portal host — both for authClient calls and so the portal's own same-origin SSR proxy has a cookie to forward. The proxy does not keep working on an arbitrary host for free. Committed configuration places the auth stack on kokweng.net:

backend/bun/fly.toml:15
COOKIE_DOMAIN = '.kokweng.net', with '.gremlin.my' present only as a comment, above the note "Change when moving domains"
backend/bun/fly.toml:11
BETTER_AUTH_URL = "https://bun-api.kokweng.net"
apps/panel/wrangler.toml:16-17
VITE_BETTER_AUTH_URL = "https://auth.kokweng.net", VITE_CALLBACK_URL = "https://panel.kokweng.net"

Configuration that may change — no backend implementation change:

  • ALLOWED_ORIGINS — add the portal origin.
  • Better Auth trusted-origin configuration — must admit the portal origin for the OAuth callback.
  • A portal wrangler.toml with its own VITE_BETTER_AUTH_URL, VITE_API_URL and VITE_CALLBACK_URL. The callback must be the portal, not panel.kokweng.net, or social sign-in returns landlords to Panel.

Sequencing. PR2 opens with a minimal second-host auth/proxy scaffold — not the moved application — so the spike does not depend on the move it is meant to gate. A local two-port rehearsal runs first where possible. The real gate is an HTTPS second-host spike before any bulk move.

  1. authClient.getSession() succeeds from the portal origin.
  2. An authenticated call through the portal's own rental SSR proxy succeeds.
  3. Session cookie name and attributes (Domain, Secure, SameSite, Path) are as expected on the portal host.
  4. Sign-in and sign-out both work from the portal.
  5. The booking return path completes.
  6. Admin denial behaves correctly for a non-admin session.
  7. Panel admin access is unchanged.

D3a — Test discovery uses explicitly enumerated Vitest projects

Both original proposals lose tests without failing. A test that is never collected reports as green, so the original "68 unit tests pass" verification would have been satisfied while a third of them never ran.

Rejected

Copy Panel's config to apps/rental

Rental files collected
41 of 68
Shared-package tests
Re-run in a second project
Failure mode
Silent

apps/panel/vitest.config.ts:19-23 includes apps/panel/src/**/*.test.tsx.tsx only. The 27 .ts rental tests would vanish with no error.

Rejected

Root test.projects: ['apps/*']

Rental files collected
Depends on per-app config
Shared-package tests
Dropped entirely
Failure mode
Points at configless apps

Only the root and apps/panel have a Vitest config; cycle-planner and clawui have none.

Selected

Enumerated projects

Rental files collected
All, both extensions
Shared-package tests
Collected once
Acceptance
File identities, not a count

Panel's own config narrows to its src and widens to {ts,tsx}. The rental config becomes a true sibling rather than a copy of a partial file.

D3b — ProofViewer stays in Panel

It has one production caller — routes/admin/rental/listings/[id].tsx:16,517 — and is admin code throughout: it imports proofStreamUrl and rentalQueryKeys from the rental API (ProofViewer.tsx:6) and every label is m.rental_admin_* or m.rental_common_retry (lines 52, 62, 80, 87). The original plan to move it to packages/ui and rewire "rental's own callers" is withdrawn — there are no rental callers.

Effect: the rental-admin surface is eight items, not six — the six admin functions plus proofStreamUrl and rentalQueryKeys.adminProof. The former "~150 lines" figure is replaced by an evidence-based inventory produced at implementation time.

D4a — Sign-in offers email and Google; anonymous is omitted

SignInPage (app.tsx:300-480) exposes three providers: signIn.email (343), signIn.social Google (365), and signIn.anonymous (376). A guest identity has no useful meaning in the portal's flows, and the tenant path already routes through a real /tenant/sign-up carrying booking intent (bookingRedirect.ts:49-52).

UI composition. The portal uses existing shared UI primitives with a plain rental background. PanelBackground is defined inline at app.tsx:85 and is not imported from Panel — no cross-app import is introduced. The route also needs auth_* keys (auth_sign_in_with_google, auth_form_signing_in, auth_error_failed_sign_in, auth_error_unexpected), which sit outside the rental_* namespace entirely — see D4c.

D4b — Return paths, role routing, and the landlord landing

Root routing precedence in the portal:

  1. Session pending

    Show loading.

  2. No session

    Go to sign-in, carrying the validated return path. Redirect loops through /sign-in are avoided explicitly.

  3. Valid intended route

    Honour it first.

  4. Fallback by role

    Landlord → /landlord/dashboard; any other authenticated user → /tenant/requests. A landlord also retains tenant access, and no new backend tenant role is introduced.

D4c — Translations split by measured usage

The original "~757 keys out, 81 stay" describes a partition that does not exist. The split is not a prefix split: the two admin screens plus ProofViewer reference 74 distinct rental_* keys spanning rental_admin, rental_form, rental_common, rental_photo, rental_proof and rental_status. Several of those namespaces are also used by the landlord app, so those keys must exist in both apps.

Translation keys divide into three sets — portal-only keys that move, keys used by both apps that must be duplicated, and Panel-only keys that stay — plus an auth namespace copied into the portal. Portal only moves Used by both duplicated Panel only stays auth_* copied in
Three sets plus the auth namespace — the overlap is why a two-way partition fails.

D5a — E2E runs two apps, with explicit URL wiring

The original plan treated per-file baseURL as the whole change. The config file does indeed need no edit — but the CI workflow is the larger part, and today it cannot run the rental specs at all:

LineWhat it does today
frontend-e2e.yml:116-122Starts the panel only
:124-134Waits for the panel alone
:112-114Builds i18n for apps/panel only
:138-142Passes no rental base URL
:75ALLOWED_ORIGINS=http://localhost:3000 — a second port is a different origin and would be rejected

Also corrected: test.use({ baseURL: process.env.RENTAL_BASE_URL }) with the variable unset yields baseURL: undefined and breaks relative navigation. Everything in this decision is test-environment configuration, distinct from a backend implementation change.

D5b — Deployment adds one isolated app

ChangeTriggers
apps/panel/** onlypanel only
apps/rental/** onlyrental only
packages/** (shared)every app whose filter lists packages/**
pnpm-lock.yamlevery app whose filter lists the lockfile
Workflow / shared configper that file's own filter entries

Panel's filter (frontend-deploy.yml:39-41) already lists frontend/solidstart/packages/** and the lockfile. The rental filter lists the same, since the portal depends on packages/ui for RentalLayout, RentalThemeBoundary and rentalTheme. Noted and deliberately unchanged: clawui's filter omits packages/**, a pre-existing inconsistency.

D6 — The rename is a scoped inventory, not a mechanical replace

/owner appears across 47 files, extending beyond the three trees that move. The string carries at least three different meanings, and a single find-and-replace cannot tell them apart:

UI-text false positive

unrelated spec
  • digital-worker-learning.spec.ts:67
  • getByText(/owner\/admin review required/i) is a regex over displayed text, not a route
  • A blind replace corrupts a digital-worker assertion

Reference outside the moving trees

resolved by D4b
  • lib/platform-role.ts:19 returns '/owner' for a landlord
  • Lives in lib/, so the "2 import edits" figure never covered it
  • Resolved by removing Panel's automatic landing

Unlisted fixture, cross-split import

missing from inventory
  • apps/panel/tests/e2e-fixtures/rentalOverflow.tsx:9
  • Imports '../../src/routes/owner/new'
  • Breaks when routes/owner/ moves; apps/panel/tests/ was absent from the file inventory

Deliberately not renamed, unchanged from the original design:

  • owner_user_id and every other wire/DB field — these must match what Go sends.
  • i18n key names (rental_owner_*, rental_shell_owner_workspace) — internal identifiers with no collision; renaming is churn.
  • User-visible English text ("Owner Workspace") — product language, ADR-blessed, editable in JSON at any time.
  • tenant — the word in the tenancy agreement (租客), i.e. legal domain vocabulary. Its collision with org multi-tenancy (租户) dissolves with the split.

Target structure

frontend/solidstart/
  packages/
    core/     (existing)  authClient, hooks, LanguageProvider
    ui/       (existing)  RentalLayout, RentalThemeBoundary, rentalTheme
  apps/
    panel/          org multi-tenant admin; keeps /admin/rental/listings
                    + features/rental-admin/ (incl. ProofViewer, D3b)
    rental/         NEW — /landlord/* + /tenant/*, orgless
    cycle-planner/
    clawui/
Full apps/rental layout
apps/rental/
  package.json          from panel's, minus @solid-primitives/websocket,
                        @tanstack/solid-table, html2canvas-pro (org-only deps)
  vite.config.ts        solidStart + tailwind + nitro (cloudflare_module)
  wrangler.toml         portal name; own VITE_BETTER_AUTH_URL / VITE_API_URL /
                        VITE_CALLBACK_URL (D2)
  vitest.config.ts      own ~ alias, setup, app-only include, {ts,tsx} (D3a)
  tsconfig.json         paths: { "~/*": ["./src/*"] }        ← same alias as panel
  i18n/{en,ms,zh}.json  usage-derived rental keys + auth_* and common keys (D4c)
  scripts/build-i18n.js merges shared-i18n + app i18n
  src/
    app.tsx             QueryClientProvider + LanguageProvider + DarkModeProvider
                        + Router. No AuthGate, no MainLayout, no org membership
                        fetch, no admin nav tree.
    lib/
      create-reactive-translator.ts   copy, unchanged
      api/error-handlers.ts           copy of the post-PR1 file, unchanged (D1)
      safeRedirect.ts                 isSafeRedirect, lifted out of app.tsx
    features/rental/**    git mv
    routes/
      sign-in.tsx                     NEW — email + Google (D4a)
      index.tsx                       NEW — landing precedence (D4b)
      landlord.tsx + landlord/**      git mv + scoped rename (D6)
      tenant.tsx + tenant/**          git mv
      api/rental/[...path].ts         git mv (Panel keeps its own copy)
      api/public/rental/[...path].ts  git mv

Impact on the existing tree

BaselineFiles
features/rental/127
routes/owner/ + owner.tsx24
routes/tenant/ + tenant.tsx22
of which are test files68 (27 .ts + 41 .tsx)
rental-only fixtures under apps/panel/tests/per D6 inventory

git mv preserves history; read later with git log --follow.

What Panel keeps and changes

ChangeNote
New features/rental-admin/ — eight items + types + status labelsD3b; sized at implementation
ProofViewer + its test move into features/rental-admin/D3b
Rewire /admin/rental/listings/* to features/rental-admin/2 files
installAIAPIErrorHandler → Panel AI module; app.tsx import updatedD1
Keeps api/rental/[...path].ts (admin traffic uses it)unchanged
Deletes api/public/rental/ proxyadmin never calls it
app.tsx: delete the three escape hatchesD6 inventory
Automatic landlord → /owner landing removedD4b
Retains only the i18n keys its own screens useD4c

Genuinely new code

  1. sign-in.tsx — email + Google (D4a); the phantom route stops working after the move.
  2. index.tsx — landing precedence (D4b).
  3. The Panel-owned AI module that receives installAIAPIErrorHandler (D1).
  4. wrangler.toml + deploy-rental job, filter, output and dispatch option (D5b).
  5. Enumerated Vitest projects (D3a).

One change outside SolidStart

// frontend/astro/apps/rental/src/pages/property/[slug].astro:72-73
const panelUrl = import.meta.env['PANEL_URL'] ?? 'https://panel.gremlin.my';
const arrangeViewingUrl = `${panelUrl}/tenant/book/${slug}`;

/tenant/book/{slug} moves to the portal, so this variable is repointed to the portal host and renamed — it no longer points at the panel. This is a top-level cross-origin navigation and needs no shared registrable domain (D2). Missing it breaks the marketplace's main conversion link; ADR-0056's Consequences are amended accordingly.

Untouched: the Go backend implementation, the database, Better Auth schema, packages/core.

Delivery — two PRs

PR1 crosses no app boundary and stands alone as cleanup if PR2 is abandoned. Code relocates only within Panel: ProofViewer into features/rental-admin/, installAIAPIErrorHandler into a Panel AI module, isSafeRedirect into lib/. The portal does not yet exist.

PR1 — decouple in place

  1. Delete dead features/rental/api/index.ts

    Node resolves ~/features/rental/api to the sibling file api.ts, so the directory's barrel is never loaded. Moving files into a new tree could silently flip resolution to the directory, so it goes first. Gate: build succeeds; rental tests collect and pass.

  2. installAIAPIErrorHandler → Panel AI module (D1)

    Drop the redundant routeAIError call from parseApiError; all signatures unchanged. Gate: AI upgrade-dialog behaviour intact; error parsing intact.

  3. isSafeRedirectlib/safeRedirect.ts

    Gate: type-check and tests pass.

  4. OwnerRoute carries a validated return path (D4b)

    Including the query string. Gate: a deep-linked landlord returns to the intended route.

  5. New features/rental-admin/ (D3b)

    Move ProofViewer + its test + admin proof helpers into it; rewire the two admin screens. Gate: admin listing review works end to end.

  6. Enumerate Vitest projects: shared-packages + panel (D3a)

    Gate: collected test file identities unchanged versus before.

PR2 — move and rename

  1. 0 · Minimal second-host auth/proxy scaffold (D2)

    Not the moved application — just enough to exercise the boundary.

  2. 1 · Local two-port rehearsal (D5a)

    Where possible. A preliminary check, not proof.

  3. 2 · SPIKE: HTTPS second host, all seven gates (D2)

    Blocks steps 3–12.

  4. 3 · Scaffold apps/rental (D4a, D4b)

    Configs, app.tsx, sign-in.tsx, index.tsx.

  5. 4 · git mv the three trees + the two proxies

    features/rental, routes/owner, routes/tenant.

  6. 5 · Scoped rename inventory (D6)

    Classify, then rename routes and identifiers; move rental-only fixtures.

  7. 6 · packages/ui theme token (D6)

    Coordinated with all real consumers.

  8. 7 · Translation inventory (D4c)

    Move exclusive, duplicate overlapping, copy auth_* and common.

  9. 8 · Delete Panel's three escape hatches (D4b)

    And remove the automatic landlord landing.

  10. 9 · Add the rental Vitest project (D3a)

  11. 10 · Two-app E2E CI (D5a)

    Both i18n builds, explicit PORT/baseURL, readiness, logs, origin allowlist.

  12. 11 · deploy-rental job + filter + output + dispatch option (D5b)

  13. 12 · Repoint and rename the marketplace's Arrange Viewing variable

PR2 exit gates:

  • Both apps build.
  • Collected test file identities account for all 68 original rental test files.
  • Rental e2e specs pass against the portal host.
  • Panel's admin listing review works end to end.
  • Genuine denied-access checks pass.
  • The marketplace's Arrange Viewing link reaches the portal, and the booking → sign-up → return path completes.

Assumptions and verification gates

These are explicit and unresolved. None is an inferred fact.

Portal host
landlord.kokweng.net is a working assumption. DNS, certificates and Cloudflare routing are unverified.
Committed vs live
Repository configuration is evidence of committed intent, not proof of deployed state. No claim here describes a running system.
Anonymous sign-in
Omitted as a conservative product choice (D4a). No backend prohibition is claimed or implied.
Counts
838 rental_*, 74 distinct admin-referenced keys, 127/24/22 files, 68 test files, 47 files containing /owner — all current-tree measurements, to be recomputed as sets at implementation time.
Landlord E2E fixture
How checkLandlordAccess() is satisfied by the seeded roles is a verification gate (D5a), not a diagnosed defect.
landingPathForRole
Whether it becomes unused or a constant pass-through is determined during implementation (D4b), not asserted now.
Shared-package test setup
Whether the shared-packages Vitest project needs the existing setup file must be audited, not assumed (D3a).
Rehearsal is not proof
The local two-port run exercises same-host port behaviour only; cross-host cookie behaviour is established solely by the D2 HTTPS spike.
Pre-launch, no redirects
That the portal has no real landlords and that no backend email links to /owner are assumptions to verify before cutover (D6), not observations of live customers.