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.
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.
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 tree | What 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.
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:15COOKIE_DOMAIN = '.kokweng.net', with'.gremlin.my'present only as a comment, above the note "Change when moving domains"backend/bun/fly.toml:11BETTER_AUTH_URL = "https://bun-api.kokweng.net"apps/panel/wrangler.toml:16-17VITE_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.tomlwith its ownVITE_BETTER_AUTH_URL,VITE_API_URLandVITE_CALLBACK_URL. The callback must be the portal, notpanel.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.
authClient.getSession()succeeds from the portal origin.- An authenticated call through the portal's own rental SSR proxy succeeds.
- Session cookie name and attributes (
Domain,Secure,SameSite,Path) are as expected on the portal host. - Sign-in and sign-out both work from the portal.
- The booking return path completes.
- Admin denial behaves correctly for a non-admin session.
- 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.
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.
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.
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:
- Session pending
Show loading.
- No session
Go to sign-in, carrying the validated return path. Redirect loops through
/sign-inare avoided explicitly. - Valid intended route
Honour it first.
- 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.
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:
| Line | What it does today |
|---|---|
frontend-e2e.yml:116-122 | Starts the panel only |
:124-134 | Waits for the panel alone |
:112-114 | Builds i18n for apps/panel only |
:138-142 | Passes no rental base URL |
:75 | ALLOWED_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
| Change | Triggers |
|---|---|
apps/panel/** only | panel only |
apps/rental/** only | rental only |
packages/** (shared) | every app whose filter lists packages/** |
pnpm-lock.yaml | every app whose filter lists the lockfile |
| Workflow / shared config | per 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 specdigital-worker-learning.spec.ts:67getByText(/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 D4blib/platform-role.ts:19returns'/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 inventoryapps/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_idand 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
| Baseline | Files |
|---|---|
features/rental/ | 127 |
routes/owner/ + owner.tsx | 24 |
routes/tenant/ + tenant.tsx | 22 |
| of which are test files | 68 (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
| Change | Note |
|---|---|
New features/rental-admin/ — eight items + types + status labels | D3b; 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 updated | D1 |
Keeps api/rental/[...path].ts (admin traffic uses it) | unchanged |
Deletes api/public/rental/ proxy | admin never calls it |
app.tsx: delete the three escape hatches | D6 inventory |
Automatic landlord → /owner landing removed | D4b |
| Retains only the i18n keys its own screens use | D4c |
Genuinely new code
sign-in.tsx— email + Google (D4a); the phantom route stops working after the move.index.tsx— landing precedence (D4b).- The Panel-owned AI module that receives
installAIAPIErrorHandler(D1). wrangler.toml+deploy-rentaljob, filter, output and dispatch option (D5b).- 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
-
Delete dead
features/rental/api/index.tsNode resolves
~/features/rental/apito the sibling fileapi.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. -
installAIAPIErrorHandler→ Panel AI module (D1)Drop the redundant
routeAIErrorcall fromparseApiError; all signatures unchanged. Gate: AI upgrade-dialog behaviour intact; error parsing intact. -
isSafeRedirect→lib/safeRedirect.tsGate: type-check and tests pass.
-
OwnerRoutecarries a validated return path (D4b)Including the query string. Gate: a deep-linked landlord returns to the intended route.
-
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. -
Enumerate Vitest projects: shared-packages + panel (D3a)
Gate: collected test file identities unchanged versus before.
PR2 — move and rename
- 0 · Minimal second-host auth/proxy scaffold (D2)
Not the moved application — just enough to exercise the boundary.
- 1 · Local two-port rehearsal (D5a)
Where possible. A preliminary check, not proof.
- 2 · SPIKE: HTTPS second host, all seven gates (D2)
Blocks steps 3–12.
- 3 · Scaffold
apps/rental(D4a, D4b)Configs,
app.tsx,sign-in.tsx,index.tsx. - 4 ·
git mvthe three trees + the two proxiesfeatures/rental,routes/owner,routes/tenant. - 5 · Scoped rename inventory (D6)
Classify, then rename routes and identifiers; move rental-only fixtures.
- 6 ·
packages/uitheme token (D6)Coordinated with all real consumers.
- 7 · Translation inventory (D4c)
Move exclusive, duplicate overlapping, copy
auth_*and common. - 8 · Delete Panel's three escape hatches (D4b)
And remove the automatic landlord landing.
- 9 · Add the rental Vitest project (D3a)
- 10 · Two-app E2E CI (D5a)
Both i18n builds, explicit PORT/baseURL, readiness, logs, origin allowlist.
- 11 ·
deploy-rentaljob + filter + output + dispatch option (D5b) - 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.netis 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
/ownerare assumptions to verify before cutover (D6), not observations of live customers.