domain change

This commit is contained in:
2026-09-28 18:08:55 +05:30
parent c9616edad9
commit eeef8851e4
34 changed files with 99 additions and 4211 deletions

View File

@@ -103,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
* sub-path is absorbed there and never reaches the global one.
*/
export const HOME_ROUTE: Record<ConsoleRole, string> = {
'nearle-admin': '/nearle/stores',
// `/nearle/stores` is not a route in this application any more — Nearle's own
// staff have their own, `nearle-platform`. Pointing at it would be exactly
// the failure the comment above describes: the router sends an unknown path
// to `*`, `*` sends it back here, and React Router resolves the loop by
// rendering nothing — a blank page with no console error.
//
// It cannot be reached today, because `login` and `restore` both refuse this
// role before a session exists. It is `/login` rather than that unreachable
// path so that if one of those two checks is ever weakened, the result is a
// sign-in screen rather than a white screen nobody can diagnose.
'nearle-admin': '/login',
'store-admin': '/admin/console',
'store-manager': '/store/console',
};

View File

@@ -1,108 +1,57 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
import type { ConsoleRole } from './roles';
/*
Two consoles, one source.
Who may sign in to the merchant console.
Nearle's own staff sign in at platform.nearledaily.com; merchants and their
branch users at app.nearledaily.com. Neither site accepts the other's accounts,
and the refusal is a refusal — not a redirect carrying a half-made session
across a domain boundary.
Merchants and their branch users sign in here. Nearle's own staff have their own
application — `nearle-platform`, a separate repository with its own deploy — and
a staff account is turned away rather than redirected: no session crosses to
another site half-made.
These run against the DEFAULT build, which is `merchant`. That default is itself
the thing most worth pinning: an unset flag is the ordinary state of a developer
machine and of any deployment not yet told about the split, and the wrong
default would turn every one of them into a platform console.
The role is not known until the password has been checked, so the refusal
happens after credentials are verified. What matters is that it happens BEFORE
the session is written: `login` refuses first and persists second, and `restore`
applies the same check to what is already in storage. A staff member who typed a
correct password must not end up signed in here with no routes to reach.
*/
const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
test('an unset flag builds the merchant console', () => {
// Nothing sets VITE_WORKSPACE under the test runner, so this is the default
// path — the same one every existing deployment takes until it is told
// otherwise.
test('this build is the merchant console, with nothing to configure', () => {
// A constant, not a build flag. The flag existed only while one codebase
// served both consoles; a variable now would be a way to deploy this
// application as something it is not.
assert.equal(WORKSPACE, 'merchant');
assert.equal(IS_PLATFORM, false);
});
test('the merchant console admits merchants and refuses Nearle staff', () => {
test('both merchant roles are admitted and Nearle staff are not', () => {
assert.equal(isAllowedHere('store-admin'), true);
assert.equal(isAllowedHere('store-manager'), true);
assert.equal(isAllowedHere('nearle-admin'), false);
});
test('every role is decided, none left to a default', () => {
// A role added later must be listed deliberately on one side or the other.
// Falling through to "allowed" would put it on both consoles silently; this
// asserts each of the three is a decision that was actually made.
for (const role of ROLES) {
assert.equal(typeof isAllowedHere(role), 'boolean', `${role} has no verdict`);
}
const allowed = ROLES.filter(isAllowedHere);
assert.equal(allowed.length, 2, `merchant admits ${allowed.join(', ')}`);
// A role added later must be listed deliberately. Falling through to allowed
// would quietly admit it to the merchant console.
const admitted = ROLES.filter(isAllowedHere);
assert.deepEqual(admitted, ['store-admin', 'store-manager']);
});
test('the refusal names the other console rather than blaming the account', () => {
test('the refusal names the platform console rather than blaming the account', () => {
const message = wrongConsoleMessage('nearle-admin');
// The host, so somebody knows where to go.
assert.match(message, /platform\.nearledaily\.com/);
// And not a word that reads as "your account is broken" — the password was
// right and the account is fine. Anyone told "failed" or "denied" goes and
// resets a working password, or asks an administrator to fix nothing.
// The password was right and the account is fine. Anyone told "failed" or
// "denied" goes and resets a working password, or asks an administrator to
// fix nothing at all.
for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) {
assert.ok(
!message.toLowerCase().includes(blame),
`the refusal reads as a fault: ${message}`,
);
assert.ok(!message.toLowerCase().includes(blame), `the refusal reads as a fault: ${message}`);
}
});
test('each role is named in words a person uses', () => {
// The message is read by whoever typed the password, not by us.
test('the refused role is named in words a person uses', () => {
assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/);
assert.match(wrongConsoleMessage('store-admin'), /Store admin/);
assert.match(wrongConsoleMessage('store-manager'), /Store user/);
});
/*
What the split actually guarantees, and what it does not.
The route blocks in `App.tsx` are chosen at runtime, so both workspaces' chunks
are compiled into either image. The unmounted one is never fetched, because
nothing routes to it — but it is present, and an earlier version of the comment
in that file claimed otherwise.
So the guarantee is NOT "the other console's code is absent". It is "the other
console's accounts cannot sign in, and its paths are not routes here". Both of
those are enforced by the two functions below, which is why they are the ones
worth pinning rather than the bundle's contents.
*/
test('the refusal does not depend on which routes happen to be mounted', () => {
// `isAllowedHere` is consulted by `login` before a session is written and by
// `restore` before one is read back. Neither goes near the router, so a
// mistake in route mounting cannot open a door that this closes.
assert.equal(isAllowedHere('nearle-admin'), IS_PLATFORM);
assert.equal(isAllowedHere('store-admin'), !IS_PLATFORM);
assert.equal(isAllowedHere('store-manager'), !IS_PLATFORM);
});
test('no role is admitted by both consoles', () => {
// The two sets must partition the roles: one home each, never two. A role in
// both would make the separation cosmetic — the account would work at either
// address and the refusal would never fire.
const platformRoles: ConsoleRole[] = ['nearle-admin'];
const merchantRoles: ConsoleRole[] = ['store-admin', 'store-manager'];
for (const role of platformRoles) {
assert.ok(!merchantRoles.includes(role), `${role} is claimed by both consoles`);
}
assert.equal(
platformRoles.length + merchantRoles.length,
ROLES.length,
'a role belongs to neither console and could sign in nowhere',
);
});

View File

@@ -1,90 +1,63 @@
import type { ConsoleRole } from './roles';
/**
* Which console this build is.
* Who may sign in to the merchant console.
*
* ── Why one codebase produces two sites ─────────────────────────────────────
* ── Why this is a constant ──────────────────────────────────────────────────
*
* Nearle's own staff work at `platform.nearledaily.com`; merchants and their
* branch users work at `app.nearledaily.com`. They are the same application
* built twice with this flag set differently, rather than two repositories,
* because every screen below the workspace split — drawers, tables, the
* assistant, the design system — is shared and would otherwise be maintained
* in two places and drift.
* There was a `VITE_WORKSPACE` build flag here while one codebase served both
* consoles and had to be told which it was. That is over: Nearle's own staff
* have their own application, `nearle-platform`, and this repository is the
* merchant console and nothing else. A flag would only be a way to deploy this
* application as something it is not.
*
* What the flag changes is which routes are mounted and which roles may sign
* in. It does not change what is compiled: the branch in `App.tsx` is evaluated
* at runtime, so both workspaces' chunks are built and served, and the
* unmounted one is simply never fetched because nothing routes to it. Removing
* it from the bundle would need the flag to be a literal at each import site,
* which is a separate piece of work and buys nothing for access control.
* ── The separation is a refusal, not a redirect ─────────────────────────────
*
* ── The default is `merchant`, deliberately ─────────────────────────────────
* A Nearle staff account is turned away here with a sentence naming where it
* belongs. It is not bounced to the other site carrying a half-made session.
*
* An unset variable is the ordinary state of a developer's machine and of any
* deployment that has not been told about this yet. Defaulting to `merchant`
* means the existing site keeps behaving exactly as it did, and the platform
* build is the one that has to be asked for. The opposite default would turn
* every un-migrated environment into a platform console the day this shipped.
* The role is not known until the password has been checked — `applogin`
* returns it — so the refusal can only happen after credentials are verified.
* `login` applies it BEFORE the session is written and `restore` applies it to
* what is already stored. That order is the point: a session persisted first
* and refused afterwards leaves somebody signed in by every measure the shell
* uses, with a nav built from a role this application serves no routes for.
*/
export type Workspace = 'platform' | 'merchant';
export const WORKSPACE = 'merchant' as const;
const CONFIGURED = (import.meta.env?.['VITE_WORKSPACE'] ?? '').trim().toLowerCase();
export const WORKSPACE: Workspace = CONFIGURED === 'platform' ? 'platform' : 'merchant';
export const IS_PLATFORM = WORKSPACE === 'platform';
/**
* Who may sign in here.
*
* The separation is a REFUSAL, not a redirect. A merchant reaching the platform
* console is told which console their account belongs to and stays where they
* are; they are not bounced across a domain boundary carrying a half-made
* session. Each site serves exactly one audience and says so.
*/
const ALLOWED: Record<Workspace, ReadonlySet<ConsoleRole>> = {
platform: new Set<ConsoleRole>(['nearle-admin']),
merchant: new Set<ConsoleRole>(['store-admin', 'store-manager']),
};
const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['store-admin', 'store-manager']);
export function isAllowedHere(role: ConsoleRole): boolean {
return ALLOWED[WORKSPACE].has(role);
return ALLOWED.has(role);
}
/**
* The other console's address, for the sentence shown to somebody in the wrong
* place.
* The platform console's address, for the sentence shown to a staff member who
* signs in at the wrong site.
*
* Named rather than derived from `location.hostname`, because the two sites are
* not a naming convention apart — they are separate deployments and either can
* move. A build that was not told falls back to the production hostnames, which
* is right far more often than saying nothing.
* A build variable rather than a constant, because the two are separate
* deployments and either can move. The fallback is the production hostname,
* which is right far more often than saying nothing would be.
*/
const OTHER_SITE: Record<Workspace, string> = {
platform: (import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.nearledaily.com',
merchant: (import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com',
};
const PLATFORM_HOST =
(import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com';
/**
* What to tell somebody whose account belongs to the other console.
*
* Names the host rather than linking to it. A live link from a sign-in screen
* to another sign-in screen reads as a redirect that failed, and this is not a
* failure — it is the right answer to the wrong door.
* Names the host rather than linking to it. A live link from one sign-in screen
* to another reads as a redirect that failed, and this is not a failure — it is
* the right answer to the wrong door.
*/
export function wrongConsoleMessage(role: ConsoleRole): string {
const site = OTHER_SITE[WORKSPACE];
return IS_PLATFORM
? `This is the Nearle platform console. ${roleWord(role)} accounts sign in at ${site}.`
: `${roleWord(role)} accounts sign in at ${site}, not here.`;
return `${roleWord(role)} accounts sign in at ${PLATFORM_HOST}, not here.`;
}
function roleWord(role: ConsoleRole): string {
switch (role) {
case 'nearle-admin':
return 'Nearle staff';
// Unreachable: both merchant roles are allowed here, so neither reaches the
// refusal. Present because the switch is exhaustive over the union and a
// missing arm would be a type error the day a role is added.
case 'store-admin':
return 'Store admin';
case 'store-manager':