Skip to content

Commit 7b047b4

Browse files
feat(cli): the console document carries the runtime branding in its title, description and Open Graph tags, written on the server, so a link preview shows the product's name (#25317)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuSQfvzuV2ZjD9aZ44eAhnVq)_ Fixes #25264 Clause-②: no Metadata-protocol: none Why `Clause-②: no`: no accept set and no public surface changes. `createConsoleStaticPlugin`'s signature and options are byte-identical, no route is added, and no schema or export moves. What changes is the content of the console HTML document the plugin already served. ## What changed `createConsoleStaticPlugin` (`packages/cli/src/utils/console.ts`, what `os serve` mounts at `/_console/`) writes the console document's head per request. Besides the base tag it already added, it now writes: - the `title` element and `og:title` from `branding.productName`; - `meta name="description"` and `og:description` from `branding.pwaDescription`; - `og:image` from `branding.logoUrl`, resolved against the request origin, written only when that gives an `http(s)` URL; - `og:url`, the request origin. Its scheme comes from `x-forwarded-proto` when a proxy states `http` or `https`, the same header REST discovery's `servers[0]` reads. **One source.** The values are read from `GET /api/v1/runtime/config`, the door the shell's early-branding script reads to set `document.title`. They are read in-process, for the visitor's own request (same host and cookies), the way the existing `/forms/:slug` redirect asks the form door. So whatever decides the served name (a saved Branding setting, the host option, `OS_PRODUCT_NAME`, a hosted runtime's per-hostname answer, a distribution's subclass) decides the tags too. Nothing is resolved a second time. The probe drops `accept-encoding`, `if-none-match`, `if-modified-since` and `range`, which would change the encoding or status of the answer rather than its content. **Values and absence.** Every value is HTML-escaped (element body and double-quoted attributes). The shell's existing `title` element is replaced (objectui ships it empty), or one is added. A missing or blank value writes no tag. With no branding at all, `og:url` is not written either and the document is byte-identical to what was served before. A composition with no `runtime/config` door (`404`) is not a failure. Any other failed read (a non-200 answer, a body that is not JSON, a thrown dispatch) serves the document as before and logs one `warn`, once per plugin instance. That is a functional degradation, so `warn`, per AGENTS.md's log-level rule. Also: a changeset (`@objectstack/cli` patch) and one paragraph in `content/docs/deployment/environment-variables.mdx` saying link previews read the same branding values. The docs file is outside the surface the claim declared; it is declared here and in the report. ## Branch The harness assigned the branch `claude/project-thread-c4vx2u`, not the repo-convention `claude/issue-25264-…`. This note is for the Duplicate Fix Guard. The card's `Claim:` names this branch. ## Lane The card carries `domain:services`, but the fix is wholly `packages/cli` (`domain:cli`). The fleet director ruled that cli seat 1 claims it; the claim comment asks triage to relabel the card. ## Tests `packages/cli/src/utils/console.branded-head.test.ts`, 17 cases, unit tier (no spawn, no kernel). The "one source" cases mount the REAL `RuntimeConfigPlugin` on a REAL `HonoHttpServer` beside the real plugin. They compare the served tags with what `GET /api/v1/runtime/config` answers to the same request, including the door's derived description. The value-shape cases use a stub door: escaping (markup, quotes, `$&` in a replacement string, `&` in a logo query), a blank or absent value writing no tag and no empty `content`, a `data:` logo writing no `og:image`, an absolute logo passing through, a shell with no `title` element, `x-forwarded-proto` taken and a bogus value ignored, and the probe forwarding host and cookies while dropping the four headers. The preservation cases pin the document byte for byte to the pre-change answer with no door, with no branding block, with a door that answers 500 (one `warn` over two requests) and with a door that throws. Ablation (committed fix first, `scripts/ablation-replace.mjs`): `readIndexHtml`'s head-writing return was replaced by a plain return. 13 of 17 went red. The 4 that stayed green are the preservation cases, which is the expected direction. Restored: blob equals HEAD, `git diff HEAD` empty. Runs (on `1bfcd481`, before the main merge): - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 325 files / 4799 tests. Two files (`test/published-subpath-console.pin.test.ts`, `test/published-subpath-hook-body.pin.test.ts`) refused because `packages/cli` was not built yet. After building cli they passed: 2 files, 29 tests. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - `dispatch-gates --commands`: 97 derived families, all exit 0. `--ran` reconciliation: 97 run, 0 NOT-MEASURED. - The dogfood files that mount this plugin: `showcase-public-form-redirect` 9/9 passed. `principal-session-console-boot` and `print-service-render` skipped themselves (no built console on this host), so NOT MEASURED here. After merging `origin/main` (`43e5a2b6`; the incoming change touched only `cloud-connection-plugin.ts`): the three console test files passed, 75/75. The integration tier is declared to CI. The full dogfood battery is n/a: no `!` in the title and no `(narrowing)` arm. ## Acceptance notes - `og:url` is the request ORIGIN, as the card specifies, not the full document URL. A deep link's preview therefore names the site root as its canonical URL. Changing that would be a separate decision on the card's wording. Carrier: none. - No localized meta, per the card's "Not here" line. --- _Generated by [Claude Code](https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9dc1f42 commit 7b047b4

4 files changed

Lines changed: 498 additions & 4 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): the console document carries the runtime branding in its `<title>`, description and Open Graph tags, written on the server, so a link preview shows the product's name
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`createConsoleStaticPlugin` (what `os serve` mounts at `/_console/`) used to serve the console shell with the empty `<title>` it ships, and no description or `og:*` tags. Only the shell's inline script set the title, in the browser, from `GET /api/v1/runtime/config`. A link-preview crawler or `curl` runs no script, so it saw a blank title.
11+
12+
Every console HTML answer (the shell and the SPA fallback) now reads the branding from that same door, in-process and for the same request, and writes:
13+
14+
- `<title>` and `og:title` from `branding.productName`, the name the script sets as the tab title;
15+
- `<meta name="description">` and `og:description` from `branding.pwaDescription`;
16+
- `og:image` from `branding.logoUrl`, resolved against the request origin;
17+
- `og:url`, the request origin (its scheme from `x-forwarded-proto` when a proxy states `http` or `https`).
18+
19+
Every value is HTML-escaped, and a missing value writes no tag. A composition that mounts no `runtime/config` door gets the document exactly as before. A branding read that fails serves the document as before and logs one `warn`.
20+
21+
Nothing to change: `OS_PRODUCT_NAME`, `OS_PWA_DESCRIPTION`, `OS_LOGO_URL`, the `RuntimeConfigPlugin` options and, under `single`, a saved Setup → Branding name and logo reach the tags the way they reach the console.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ Branding precedence, by tenancy posture (`OS_TENANCY_POSTURE`):
152152

153153
On every posture `productShortName`, `pwaDescription` and `pwaThemeColor` follow the name and colour actually served unless set themselves, and `OS_PRODUCT_STAGE` and `OS_FAVICON_URL` have no setting above them.
154154

155+
Link previews read the same values. The console document served at `/_console/` carries them in tags written on the server, since a preview crawler runs no script: `<title>` and `og:title` (`productName`), `<meta name="description">` and `og:description` (`pwaDescription`), and `og:image` (`logoUrl`, resolved against the request origin). `og:url` is the request origin. A value that is not served writes no tag.
156+
155157
---
156158

157159
## Email
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The console document's server-written `<title>`, description and Open Graph
5+
* tags.
6+
*
7+
* A link-preview crawler runs no script, so the shell's early-branding script
8+
* (which sets `document.title` from `GET /api/v1/runtime/config`) never names
9+
* the product to it. `createConsoleStaticPlugin` writes the tags into the HTML
10+
* from the same door, asked in-process for the same request.
11+
*
12+
* The "one source" cases run the REAL plugin on a REAL Hono app (the
13+
* `HonoHttpServer` production resolves as `http.server`) beside the REAL
14+
* `RuntimeConfigPlugin`, and compare the served tags against what that door
15+
* answers to the same request. The value-shape cases register a stub door, so
16+
* each one says which answer the tags follow.
17+
*/
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import fs from 'node:fs';
21+
import os from 'node:os';
22+
import path from 'node:path';
23+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
24+
import { RuntimeConfigPlugin } from '@objectstack/cloud-connection';
25+
26+
import { createConsoleStaticPlugin } from './console.js';
27+
28+
const ORIGIN = 'http://share.example.test';
29+
30+
/** The shell as objectui ships it, cut down: an empty `<title>` and no meta tags. */
31+
const SHELL = '<!doctype html><html><head><meta charset="UTF-8"><title></title></head><body>console</body></html>';
32+
33+
let consoleRoot: string;
34+
let distPath: string;
35+
36+
beforeAll(() => {
37+
consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-branded-head-'));
38+
distPath = path.join(consoleRoot, 'dist');
39+
fs.mkdirSync(distPath);
40+
fs.writeFileSync(path.join(distPath, 'index.html'), SHELL);
41+
});
42+
43+
afterAll(() => {
44+
fs.rmSync(consoleRoot, { recursive: true, force: true });
45+
});
46+
47+
interface Mounted {
48+
request(p: string, init?: RequestInit): Promise<Response>;
49+
warnings: string[];
50+
probeHeaders: Headers[];
51+
}
52+
53+
type StubDoor =
54+
| { kind: 'json'; body: unknown }
55+
| { kind: 'status'; status: number }
56+
| { kind: 'throw' };
57+
58+
async function mount(opts: { runtimeConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0]; stub?: StubDoor }): Promise<Mounted> {
59+
const server = new HonoHttpServer(0);
60+
server.installNotFoundSeam();
61+
const warnings: string[] = [];
62+
const probeHeaders: Headers[] = [];
63+
const logger = { warn: (m: string) => warnings.push(m), info: () => {}, debug: () => {}, error: () => {} };
64+
65+
if (opts.runtimeConfig) {
66+
const hooks: Array<() => Promise<void>> = [];
67+
const plugin = new RuntimeConfigPlugin(opts.runtimeConfig);
68+
await plugin.start({
69+
hook: (name: string, fn: () => Promise<void>) => { if (name === 'kernel:ready') hooks.push(fn); },
70+
getService: (name: string) => {
71+
if (name === 'http.server') return server;
72+
throw new Error(`no service ${name}`);
73+
},
74+
logger,
75+
} as any);
76+
for (const fn of hooks) await fn();
77+
}
78+
79+
const app = server.getRawApp();
80+
if (opts.stub) {
81+
const stub = opts.stub;
82+
app.get('/api/v1/runtime/config', (c: any) => {
83+
probeHeaders.push(new Headers(c.req.raw.headers));
84+
if (stub.kind === 'throw') throw new Error('door exploded');
85+
if (stub.kind === 'status') return c.json({ code: 'BROKEN' }, stub.status);
86+
return c.json(stub.body);
87+
});
88+
}
89+
90+
const plugin = createConsoleStaticPlugin(distPath);
91+
await plugin.start({ getServiceAsync: async () => server, logger });
92+
return {
93+
request: async (p: string, init?: RequestInit) => app.request(`${ORIGIN}${p}`, init),
94+
warnings,
95+
probeHeaders,
96+
};
97+
}
98+
99+
/** The `content` of the one meta tag with this `name`/`property`, or `null`; two such tags fail. */
100+
function metaContent(html: string, key: string): string | null {
101+
const found = [...html.matchAll(/<meta (?:name|property)="([^"]+)" content="([^"]*)">/g)].filter((m) => m[1] === key);
102+
expect(found.length, `meta ${key} written ${found.length} times`).toBeLessThanOrEqual(1);
103+
return found[0]?.[2] ?? null;
104+
}
105+
106+
function titles(html: string): string[] {
107+
return [...html.matchAll(/<title>([\s\S]*?)<\/title>/g)].map((m) => m[1]);
108+
}
109+
110+
describe('the console document carries the runtime branding, read from the door the early script reads', () => {
111+
const branding = { productName: 'Acme Cloud', pwaDescription: 'Acme operations console', logoUrl: '/brand/logo.svg' };
112+
113+
it('writes the title, description and the four Open Graph tags', async () => {
114+
const app = await mount({ runtimeConfig: branding });
115+
const res = await app.request('/_console/');
116+
expect(res.status).toBe(200);
117+
const html = await res.text();
118+
119+
expect(titles(html)).toEqual(['Acme Cloud']);
120+
expect(metaContent(html, 'description')).toBe('Acme operations console');
121+
expect(metaContent(html, 'og:title')).toBe('Acme Cloud');
122+
expect(metaContent(html, 'og:description')).toBe('Acme operations console');
123+
expect(metaContent(html, 'og:image')).toBe(`${ORIGIN}/brand/logo.svg`);
124+
expect(metaContent(html, 'og:url')).toBe(ORIGIN);
125+
expect(app.warnings).toEqual([]);
126+
});
127+
128+
it('writes exactly what GET /api/v1/runtime/config answers to the same request', async () => {
129+
const app = await mount({ runtimeConfig: branding });
130+
const served = (await (await app.request('/api/v1/runtime/config')).json()).branding;
131+
const html = await (await app.request('/_console/')).text();
132+
133+
expect(titles(html)).toEqual([served.productName]);
134+
expect(metaContent(html, 'og:title')).toBe(served.productName);
135+
expect(metaContent(html, 'description')).toBe(served.pwaDescription);
136+
expect(metaContent(html, 'og:image')).toBe(new URL(served.logoUrl, `${ORIGIN}/`).href);
137+
});
138+
139+
it('follows the door\'s DERIVED description: unset, it is built from the product name served', async () => {
140+
const app = await mount({ runtimeConfig: { productName: 'Acme Cloud' } });
141+
const served = (await (await app.request('/api/v1/runtime/config')).json()).branding;
142+
const html = await (await app.request('/_console/')).text();
143+
144+
expect(served.pwaDescription).toContain('Acme Cloud');
145+
expect(metaContent(html, 'description')).toBe(served.pwaDescription);
146+
});
147+
148+
it('writes the tags on every HTML answer: an SPA fallback and a direct .html hit', async () => {
149+
const app = await mount({ runtimeConfig: branding });
150+
for (const p of ['/_console/apps/crm/record/42', '/_console/index.html']) {
151+
const html = await (await app.request(p)).text();
152+
expect(titles(html), p).toEqual(['Acme Cloud']);
153+
expect(metaContent(html, 'og:url'), p).toBe(ORIGIN);
154+
}
155+
});
156+
157+
it('keeps the base tag the shell was already given', async () => {
158+
const app = await mount({ runtimeConfig: branding });
159+
const html = await (await app.request('/_console/')).text();
160+
expect(html).toContain('<base href="/_console/">');
161+
});
162+
});
163+
164+
describe('every value is escaped, and a missing value writes no tag', () => {
165+
it('escapes markup and quotes in the title and in every attribute', async () => {
166+
const app = await mount({
167+
stub: {
168+
kind: 'json',
169+
body: { branding: { productName: 'A<b>"&\'$&', pwaDescription: '"><script>x()</script>', logoUrl: '/l.png?a=1&b=2' } },
170+
},
171+
});
172+
const html = await (await app.request('/_console/')).text();
173+
174+
expect(html).not.toContain('<script>x()');
175+
expect(html).not.toContain('<b>');
176+
expect(titles(html)).toEqual(['A&lt;b&gt;&quot;&amp;&#39;$&amp;']);
177+
expect(metaContent(html, 'og:title')).toBe('A&lt;b&gt;&quot;&amp;&#39;$&amp;');
178+
expect(metaContent(html, 'description')).toBe('&quot;&gt;&lt;script&gt;x()&lt;/script&gt;');
179+
expect(metaContent(html, 'og:image')).toBe(`${ORIGIN}/l.png?a=1&amp;b=2`);
180+
});
181+
182+
it('a branding with only a product name writes no description and no image tag', async () => {
183+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme', pwaDescription: ' ', logoUrl: '' } } } });
184+
const html = await (await app.request('/_console/')).text();
185+
186+
expect(titles(html)).toEqual(['Acme']);
187+
expect(metaContent(html, 'og:title')).toBe('Acme');
188+
expect(metaContent(html, 'og:url')).toBe(ORIGIN);
189+
expect(metaContent(html, 'description')).toBeNull();
190+
expect(metaContent(html, 'og:description')).toBeNull();
191+
expect(metaContent(html, 'og:image')).toBeNull();
192+
expect(html).not.toContain('content=""');
193+
});
194+
195+
it('a logo that does not resolve to an http(s) URL writes no og:image', async () => {
196+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme', logoUrl: 'data:image/png;base64,AAAA' } } } });
197+
const html = await (await app.request('/_console/')).text();
198+
expect(metaContent(html, 'og:image')).toBeNull();
199+
});
200+
201+
it('an absolute logo URL is written as it is', async () => {
202+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme', logoUrl: 'https://cdn.example.com/acme.png' } } } });
203+
const html = await (await app.request('/_console/')).text();
204+
expect(metaContent(html, 'og:image')).toBe('https://cdn.example.com/acme.png');
205+
});
206+
207+
it('a shell with no <title> element gets one', async () => {
208+
const bare = path.join(consoleRoot, 'bare-dist');
209+
fs.mkdirSync(bare, { recursive: true });
210+
fs.writeFileSync(path.join(bare, 'index.html'), '<!doctype html><html><head></head><body></body></html>');
211+
const server = new HonoHttpServer(0);
212+
server.installNotFoundSeam();
213+
const app = server.getRawApp();
214+
app.get('/api/v1/runtime/config', (c: any) => c.json({ branding: { productName: 'Acme' } }));
215+
await createConsoleStaticPlugin(bare).start({ getServiceAsync: async () => server, logger: { warn: () => {} } });
216+
const html = await (await app.request(`${ORIGIN}/_console/`)).text();
217+
expect(titles(html)).toEqual(['Acme']);
218+
});
219+
});
220+
221+
describe('og:url is the origin the visitor addressed', () => {
222+
it('takes the scheme a proxy states in x-forwarded-proto', async () => {
223+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme', logoUrl: '/l.svg' } } } });
224+
const html = await (await app.request('/_console/', { headers: { 'x-forwarded-proto': 'https' } })).text();
225+
expect(metaContent(html, 'og:url')).toBe('https://share.example.test');
226+
expect(metaContent(html, 'og:image')).toBe('https://share.example.test/l.svg');
227+
});
228+
229+
it('ignores an x-forwarded-proto that is not http or https', async () => {
230+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme' } } } });
231+
const html = await (await app.request('/_console/', { headers: { 'x-forwarded-proto': 'javascript' } })).text();
232+
expect(metaContent(html, 'og:url')).toBe(ORIGIN);
233+
});
234+
});
235+
236+
describe('the branding read is the visitor\'s own request, re-addressed', () => {
237+
it('forwards the host and cookies, and drops the headers that would change the answer\'s encoding or status', async () => {
238+
const app = await mount({ stub: { kind: 'json', body: { branding: { productName: 'Acme' } } } });
239+
await app.request('/_console/', {
240+
headers: { host: 'share.example.test', cookie: 'session=abc', 'accept-encoding': 'gzip', 'if-none-match': '"x"', accept: 'text/html' },
241+
});
242+
expect(app.probeHeaders).toHaveLength(1);
243+
const sent = app.probeHeaders[0];
244+
expect(sent.get('cookie')).toBe('session=abc');
245+
expect(sent.get('host')).toBe('share.example.test');
246+
expect(sent.get('accept-encoding')).toBeNull();
247+
expect(sent.get('if-none-match')).toBeNull();
248+
expect(sent.get('accept')).toBe('application/json');
249+
});
250+
});
251+
252+
describe('with no branding to read, the document is served as before', () => {
253+
/** The shell as it was served before this change: the base tag added, nothing else. */
254+
const BEFORE = SHELL.replace('<head>', '<head>\n <base href="/_console/">');
255+
256+
it('a composition with no branding door serves the shell unchanged, and says nothing', async () => {
257+
const app = await mount({});
258+
const html = await (await app.request('/_console/')).text();
259+
expect(html).toBe(BEFORE);
260+
expect(app.warnings).toEqual([]);
261+
});
262+
263+
it('a door that answers no branding block serves the shell unchanged', async () => {
264+
const app = await mount({ stub: { kind: 'json', body: { cloudUrl: '' } } });
265+
expect(await (await app.request('/_console/')).text()).toBe(BEFORE);
266+
});
267+
268+
it('a door that fails serves the shell unchanged and warns once', async () => {
269+
const app = await mount({ stub: { kind: 'status', status: 500 } });
270+
expect(await (await app.request('/_console/')).text()).toBe(BEFORE);
271+
expect(await (await app.request('/_console/apps/x')).text()).toBe(BEFORE);
272+
expect(app.warnings).toHaveLength(1);
273+
expect(app.warnings[0]).toContain('/api/v1/runtime/config');
274+
expect(app.warnings[0]).toContain('500');
275+
});
276+
277+
it('a door that throws serves the shell unchanged and warns once', async () => {
278+
const app = await mount({ stub: { kind: 'throw' } });
279+
const res = await app.request('/_console/');
280+
expect(res.status).toBe(200);
281+
expect(await res.text()).toBe(BEFORE);
282+
expect(app.warnings).toHaveLength(1);
283+
});
284+
});

0 commit comments

Comments
 (0)