diff --git a/.changeset/ai-chat-public-share-base-4482.md b/.changeset/ai-chat-public-share-base-4482.md new file mode 100644 index 0000000000..3ad06d0251 --- /dev/null +++ b/.changeset/ai-chat-public-share-base-4482.md @@ -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 ``, 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 `` 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 ``, 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 +`` element for each deployment shape, plus a structural case asserting no other +app-shell file reads the `` tag — so a fourth copy fails a test rather than +waiting for mount semantics to change under it. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 58084f8dcb..4763669391 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -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, @@ -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 `` 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 → 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 `` — 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, diff --git a/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx b/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx new file mode 100644 index 0000000000..1f41e684d5 --- /dev/null +++ b/packages/app-shell/src/console/organizations/__tests__/resolvePublicShareBase.browser.test.tsx @@ -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 ``, 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 `` 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 + * `` tag and by nothing else, so every case below sets a real + * `` element in the document — the same mechanism the framework CLI + * writes into the served HTML (``) 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 `` (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 `` tag (always the origin root) + * reddens the two mount cases — `/_console/` and the port/scheme one — while + * the root-mount and no-`` 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 `` 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-`` 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 mechanism)', () => { + afterEach(() => { + setBaseHref(null); + vi.unstubAllGlobals(); + history.pushState({}, '', '/'); + }); + + it('lands under the console mount the framework CLI injects', () => { + // `` — 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 at all', () => { + // Standalone / `os dev` runs ship no ; the SPA is root-mounted. + setBaseHref(null); + expect(resolvePublicShareBase()).toBe(`${window.location.origin}/s`); + }); + + it('ignores the current SPA route when no 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 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 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 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); + }); +}); diff --git a/packages/app-shell/src/console/organizations/resolveHomeUrl.ts b/packages/app-shell/src/console/organizations/resolveHomeUrl.ts index 48af126635..c04403cbd4 100644 --- a/packages/app-shell/src/console/organizations/resolveHomeUrl.ts +++ b/packages/app-shell/src/console/organizations/resolveHomeUrl.ts @@ -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 ``, 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'); +}