Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/ai-chat-public-share-base-4482.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@object-ui/app-shell': patch
---

`AiChatPage`'s public share-link base now resolves through the one console-mount
resolver instead of a private copy of it (objectui#4482).

The page built `publicShareBase` itself — read the injected `<base href>`, take its
pathname, trim trailing slashes, concatenate `${origin}${base}/s` — which was the third
independent implementation of the mount resolution `resolveConsoleUrl` centralizes.
objectui#4472 had just deleted the other two on that rule; this was the surviving
sibling. Its output was correct, so nothing a user hits was broken and nothing a user
hits changes: measured over the base-href matrix, the deleted builder and
`resolveConsoleUrl('s')` return identical URLs for every shape the console is served in
— `/_console/` (the only href the framework CLI injects), `/` root mounts, `./` portable
builds, nested mounts, and no `<base>` at all.

The `/s` resolution now lives beside its three siblings as `resolvePublicShareBase()`,
which keeps the one thing a bare `resolveConsoleUrl('s')` call would drop: with no DOM
it returns `undefined` rather than a URL built from an origin that does not exist, so
`ShareDialog` applies its own fallback. It deliberately takes no `baseURI` argument —
the mount is only ever carried by the injected `<base href>`, and a resolver with no
other input cannot be pinned by a test that steers something production never reads.

`resolvePublicShareBase.browser.test.tsx` pins the resolved base against a real injected
`<base>` element for each deployment shape, plus a structural case asserting no other
app-shell file reads the `<base>` tag — so a fourth copy fails a test rather than
waiting for mount semantics to change under it.
19 changes: 5 additions & 14 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ import { useAdapter } from '../../providers/AdapterProvider.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError.js';
import { resolveKeyedI18nLabel } from '../../utils/index.js';
import { resolvePublicShareBase } from '../organizations/resolveHomeUrl.js';
import { ExcelImportBar } from './ExcelImportBar.js';
import {
Select,
Expand DownExpand Up@@ -980,20 +981,10 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro

// Public share-link landing base. SharedRecordPage lives UNDER the console
// SPA basename (e.g. `/_console/s/:token`), so the ShareDialog default of
// `${origin}/s/:token` 404s for recipients. Derive the base from the SPA's
// BASE_URL so the copyable link points at the actually-served route.
const publicShareBase = useMemo(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
// Mirror the console's own basename resolution (App.tsx resolveBasename):
// the published SPA uses a relative Vite base, so the mount path is carried
// by the injected `<base href>` tag, NOT import.meta.env.BASE_URL.
let base = '';
try {
const href = document.querySelector('base')?.getAttribute('href');
if (href) base = new URL(href, window.location.origin).pathname.replace(/\/+$/, '');
} catch { /* no <base> → root-mounted SPA */ }
return `${window.location.origin}${base}/s`;
}, []);
// `${origin}/s/:token` 404s for recipients. The mount comes from the one
// console-mount resolver, which reads the injected `<base href>` — this used
// to be a third hand-rolled copy of that resolution (objectui#4482).
const publicShareBase = useMemo(() => resolvePublicShareBase(), []);

// New-conversation race guard. On an IN-SPA `/ai?new=1` navigation the
// URL-mirroring effect below fires in the SAME commit as the hook's effect,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// @vitest-environment happy-dom

/**
* The public share-link base resolves through the ONE console-mount resolver
* (objectui#4482).
*
* ## What was wrong, given nothing was broken
*
* `AiChatPage` built `publicShareBase` itself: read `<base href>`, take its
* pathname, trim trailing slashes, concatenate `${origin}${base}/s`. Measured
* output was correct for every deployment shape the console is actually served
* in — this card is drift risk, not a defect. It was the third independent copy
* of a resolution `resolveConsoleUrl` already centralizes; objectui#4472 had
* just deleted the other two. A correct-today duplicate is precisely the shape
* that rots: the next change to mount semantics updates the helper and misses
* the copy.
*
* So a green suite proves nothing by itself here, and these cases are written
* to be discriminating rather than merely passing — see the reverse
* verification at the bottom of this header.
*
* ## Why the pins are driven by an injected `<base>` element
*
* PR #4480 recorded the trap: `vi.stubEnv('BASE_URL', …)` does nothing against
* the `import.meta.env.BASE_URL` spelling, because Vite inlines that at
* transform time. A test steering it is permanently green while testing
* nothing the production path reads. The mount is carried by the injected
* `<base href>` tag and by nothing else, so every case below sets a real
* `<base>` element in the document — the same mechanism the framework CLI
* writes into the served HTML (`<base href="${CONSOLE_PATH}/">`) and the same
* one the router's own basename resolution reads (`apps/console/src/App.tsx`).
*
* ## Equivalence measured at fix time
*
* Old builder vs `resolveConsoleUrl('s')` over the base-href matrix: identical
* output for every reachable input — `/_console/` (the CLI's only injection,
* always trailing-slashed), `/` (root mount), no `<base>` (dev/standalone),
* `./` (portable build), and nested mounts. They differ only for inputs
* nothing emits: a base href with NO trailing slash, and a cross-origin
* absolute base href. In both the shared resolver follows the HTML base-URL
* semantics the router and the SPA's relative asset URLs already live by,
* while the deleted copy treated the href as a directory prefix and forced the
* document origin. Recorded on the issue rather than fixed here — changing
* `consoleRoot()` would move `/home` and org-switch navigation too.
*
* ## Reverse verification (performed when written)
*
* - Making `consoleRoot()` ignore the `<base>` tag (always the origin root)
* reddens the two mount cases — `/_console/` and the port/scheme one — while
* the root-mount and no-`<base>` cases stay green, because they resolve to
* the same URL either way. That split is the point: it shows the mount cases
* are measuring the mechanism and not a constant. It also reddens the scan
* self-check below, which was NOT predicted and is worth writing down: that
* case asserts the resolver still contains a `<base>` read, and this ablation
* deletes precisely that. The coupling is correct — "the resolver is the one
* place that reads the tag" is false once the resolver stops reading it — but
* it means the self-check is not independent of `consoleRoot()`'s body.
* - Deleting the no-DOM guard from `resolvePublicShareBase` reddens the SSR
* case by THROWING (`resolveConsoleUrl` reaches `window.location.origin`
* through an undefined `window`), not by returning a wrong string.
* - Re-introducing a hand-rolled `document.querySelector('base')` into
* `AiChatPage` reddens the one-resolver case with that path named.
* - Pointing `consoleRoot()`'s no-`<base>` fallback at `document.baseURI`
* reddens only the deep-route case — the regression `resolveHomeUrl`'s
* header already records, here for the share base.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePublicShareBase } from '../resolveHomeUrl';

function setBaseHref(href: string | null): void {
document.head.querySelectorAll('base').forEach((el) => el.remove());
if (href != null) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
}

describe('resolvePublicShareBase (injected <base href> mechanism)', () => {
afterEach(() => {
setBaseHref(null);
vi.unstubAllGlobals();
history.pushState({}, '', '/');
});

it('lands under the console mount the framework CLI injects', () => {
// `<base href="/_console/">` — packages/cli/src/utils/console.ts writes
// exactly this (CONSOLE_PATH + '/'). A recipient opening the copied link
// must reach SharedRecordPage at /_console/s/:token, not /s/:token.
setBaseHref('/_console/');
expect(resolvePublicShareBase()).toBe(`${window.location.origin}/_console/s`);
});

it('is the origin root on a root-mounted deployment', () => {
setBaseHref('/');
expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
});

it('is the origin root when the host injected no <base> at all', () => {
// Standalone / `os dev` runs ship no <base>; the SPA is root-mounted.
setBaseHref(null);
expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
});

it('ignores the current SPA route when no <base> is present', () => {
// The share dialog opens from deep inside the chat surface. Resolving
// against the current document URL would produce /ai/agent/s — the same
// family of bug as resolveHomeUrl's /home/home/home regression.
setBaseHref(null);
history.pushState({}, '', '/ai/support_agent/conv_123');
expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`);
});

it('carries a non-default port and scheme through unchanged', () => {
setBaseHref('/_console/');
const url = new URL(resolvePublicShareBase()!);
expect(url.origin).toBe(window.location.origin);
// Regression the resolver family exists for: `${origin}${BASE_URL}` with a
// relative Vite base produced a trailing-dot host in production.
expect(url.hostname.endsWith('.')).toBe(false);
expect(url.pathname).toBe('/_console/s');
});

it('returns undefined with no DOM instead of guessing an origin', () => {
// ShareDialog then applies its own fallback. A string built here without a
// window would be a link to a host that does not exist.
setBaseHref('/_console/');
vi.stubGlobal('window', undefined);
expect(resolvePublicShareBase()).toBeUndefined();
});
});

/**
* The structural half. The behavioural cases above cannot tell "routes through
* the shared resolver" from "hand-rolls the same answer correctly" — which is
* the entire subject of this card, and what the next copy will look like.
*/
describe('objectui#4482 — app-shell reads <base href> in exactly one place', () => {
const here = path.dirname(fileURLToPath(import.meta.url));
const srcRoot = path.resolve(here, '../../..');
const RESOLVER = path.join(srcRoot, 'console/organizations/resolveHomeUrl.ts');
const BASE_TAG_READ = /(querySelector|querySelectorAll|getElementsByTagName)\(\s*['"`]base['"`]\s*\)/;

function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
// Tests legitimately create and clear <base> elements — including this
// file, which must not fence itself out of existence.
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
}
return out;
}

const files = walk(srcRoot);

it('is reading the real tree, not an empty one', () => {
expect(files.length).toBeGreaterThan(200);
expect(files).toContain(RESOLVER);
});

it('no app-shell file outside the resolver reads the <base> tag', () => {
const offenders = files.filter(
(f) => f !== RESOLVER && BASE_TAG_READ.test(readFileSync(f, 'utf8')),
);
expect(offenders.map((f) => path.relative(srcRoot, f))).toEqual([]);
});

it('the scan can see a hand-rolled read (it is not a regex that never matches)', () => {
// Without this the case above would pass just as well spelled wrong. This
// is the exact line deleted from AiChatPage.
expect(BASE_TAG_READ.test("document.querySelector('base')?.getAttribute('href')")).toBe(true);
expect(BASE_TAG_READ.test(readFileSync(RESOLVER, 'utf8'))).toBe(true);
});
});
32 changes: 32 additions & 0 deletions packages/app-shell/src/console/organizations/resolveHomeUrl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,3 +58,35 @@ export function resolveRootUrl(baseURI?: string): string {
export function resolveConsoleUrl(path: string, baseURI?: string): string {
return new URL(path.replace(/^\//, ''), resolveRootUrl(baseURI)).toString();
}

/**
* Resolve the public share-link landing base — the console-mounted `/s` route
* that `SharedRecordPage` serves (`/_console/s/:token` on a CLI-served
* deployment). `ShareDialog` appends `/:token` to it for the link a user
* copies and hands to a recipient.
*
* `ShareDialog`'s own default is `${origin}/s/:token`, which 404s wherever the
* console is not root-mounted. The link is a full-page destination pasted into
* another browser, so it needs exactly the deployment-mount resolution the
* navigations above already do — and it takes it from {@link resolveConsoleUrl}
* rather than a private copy. This helper replaced the third hand-rolled
* base-href reader in the console (objectui#4482); objectui#4472 removed the
* other two. One resolver, so the next change to mount semantics cannot reach
* some call sites and miss others.
*
* Returns `undefined` — not a guessed URL — with no DOM, so `ShareDialog`
* applies its own fallback instead of rendering a link built from an origin
* that does not exist. That guard is the one thing this adds over a bare
* `resolveConsoleUrl('s')` call.
*
* Deliberately takes no `baseURI` argument, unlike its three siblings: the
* mount is only ever carried by the injected `<base href>`, and a resolver
* with no other input cannot be pinned by a test that steers something the
* production path never reads (the `vi.stubEnv('BASE_URL', …)` trap recorded
* in objectui#4482 — Vite inlines `import.meta.env` at transform time, so such
* a test is permanently green).
*/
export function resolvePublicShareBase(): string | undefined {
if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
return resolveConsoleUrl('s');
}
Loading