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
35 changes: 35 additions & 0 deletions .changeset/serve-banner-external-base-url.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/cli": patch
---

**Bug fix (wrong address printed):** the `os serve` / `os dev` ready banner now builds its API, Console and MCP links from the origin an operator can actually reach, instead of composing `http://localhost:<port>` from the port the process happens to bind (#10646).

Measured on the EE 4.1.0 published-image compose stack (moved from cloud#1507). The app container `expose`s `:3000` with no `ports:` mapping — unreachable from the host, and less so still under `--scale app=N` — while the published entry point is Caddy on `:80`, and compose has already resolved `OS_AUTH_URL` to `http://localhost`. The banner printed the container-internal address anyway:

```
➜ API: http://localhost:3000/
➜ Console: http://localhost:3000/_console/
➜ MCP: http://localhost:3000/api/v1/mcp
connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill
```

Following the Console link failed outright; after moving the deployment to a domain the banner still said `localhost:3000`; and the `MCP:` line is the address customers paste into an AI client, where a wrong absolute URL never fails loudly — it just never connects.

**The origin is the runtime own answer, not a second one.** The banner resolves it through `resolveAuthBaseUrl` — the same function whose `baseOrigin` is pushed onto the CSRF allow-list a few hundred lines earlier in the same boot — so the banner and the origin the deployment actually trusts cannot drift apart. That chain is `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:<port>`; the legacy name sits in the middle and is easy to miss when the chain is restated from memory, which is one reason it is read rather than restated. Nothing about what the server listens on, binds to, or advertises to a client changed: the resolver reads `process.env` and the bound port, and this fix changes only printed text.

**When no origin can be determined, the banner prints no absolute URL at all.** The chain yields nothing usable when a variable is set-but-empty (`OS_AUTH_URL=` stops the chain rather than falling through) or carries no scheme. The banner then prints the paths bare —

```
➜ API: /
➜ Console: /_console/
➜ MCP: /api/v1/mcp
connect an AI client (Claude Code, Cursor, …) · skill: /api/v1/mcp/skill
paths only — this deployment external base URL could not be resolved;
set OS_AUTH_URL to its public origin (e.g. https://app.example.com)
```

— because a missing address sends the operator to look one up, while a confident wrong one gets copied. `http://localhost:3000` was never a neutral default here; it was the wrong answer that shipped.

The local dev loop is unchanged: with nothing set, the tail of the chain is still `http://localhost:<port>` on the port that was actually bound (past any dev auto-shift), so `os dev` keeps its clickable Console link.

Structurally, `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`. The banner no longer knows the port, so it cannot compose an address from one, and a caller that fails to resolve an origin is a compile error rather than a plausible-looking line of output.
134 changes: 134 additions & 0 deletions packages/cli/src/commands/serve-banner-external-base-url.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// framework#10646 — the banner's external base URL, end to end.
//
// `format.server-ready-base-url.test.ts` pins what the banner PRINTS for a
// given origin. This file pins the other half: that the origin comes from the
// runtime's OWN precedence chain and not from a banner-local copy of it. It
// drives `resolveAuthBaseUrl` from a real environment — the same call serve's
// banner site makes — and feeds its `baseOrigin` straight into
// `printServerReady`, so a drift in either half fails here.
//
// The chain is deliberately NOT restated as a literal in this file. It is
// `resolveAuthBaseUrl`'s, whose `baseOrigin` is also what gets pushed onto the
// CSRF allow-list: if the banner and the deployment's trusted origin could
// disagree, one of the two would be wrong, and the banner is the one nobody
// checks.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveAuthBaseUrl } from './serve.js';
import { printServerReady, type ServerReadyOptions } from '../utils/format.js';

const TOUCHED = ['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL'] as const;

describe('server-ready banner external base URL (#10646)', () => {
const saved: Partial<Record<(typeof TOUCHED)[number], string | undefined>> = {};

const bannerOpts: Omit<ServerReadyOptions, 'externalBaseOrigin'> = {
configFile: 'objectstack.config.ts',
isDev: false,
pluginCount: 1,
uiEnabled: true,
consolePath: '/_console',
mcpEnabled: true,
};

let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
for (const n of TOUCHED) {
saved[n] = process.env[n];
delete process.env[n];
}
lines = [];
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' ').replace(/\u001b\[[0-9;]*m/g, ''));
});
});

afterEach(() => {
spy.mockRestore();
for (const n of TOUCHED) {
if (saved[n] === undefined) delete process.env[n];
else process.env[n] = saved[n];
}
});

/**
* Exactly what serve's banner call site does: resolve through the runtime
* chain, print what came back. `port` is the port the server actually bound.
*/
const bootBanner = (port: number, extra: Partial<ServerReadyOptions> = {}) => {
printServerReady({
...bannerOpts,
...extra,
externalBaseOrigin: resolveAuthBaseUrl(port).baseOrigin,
});
return lines.join('\n');
};

it('prints the EE compose stack published origin, not the exposed-only port', () => {
// The measured repro (moved from cloud#1507): compose resolves
// OS_AUTH_URL to the Caddy entry point while the app binds :3000 behind it
// with no `ports:` mapping.
process.env.OS_AUTH_URL = 'http://localhost';

const banner = bootBanner(3000);

expect(banner).toContain('http://localhost/_console/');
expect(banner).toContain('http://localhost/api/v1/mcp');
expect(banner).not.toContain('localhost:3000');
});

it('follows the deployment onto its domain once OS_AUTH_URL is an https origin', () => {
process.env.OS_AUTH_URL = 'https://app.example.com';

const banner = bootBanner(3000);

expect(banner).toContain('https://app.example.com/api/v1/mcp');
expect(banner).not.toContain('localhost');
});

it('honours the rest of the chain — the legacy name, then OS_BASE_URL', () => {
process.env.BETTER_AUTH_URL = 'https://legacy.example.com';
process.env.OS_BASE_URL = 'https://base.example.com';
expect(bootBanner(3000)).toContain('https://legacy.example.com/api/v1/mcp');

lines.length = 0;
delete process.env.BETTER_AUTH_URL;
expect(bootBanner(3000)).toContain('https://base.example.com/api/v1/mcp');
});

it('keeps the local dev loop on the bound port when nothing is set', () => {
// The tail of the chain. Includes the dev auto-shift: 3000 busy -> 3001,
// and the banner must name the port that was actually bound.
expect(bootBanner(3001)).toContain('http://localhost:3001/_console/');
});

it('prints paths only when a set-but-empty OS_AUTH_URL breaks the chain', () => {
// An empty value is NOT an unset one: the chain stops there, so neither
// OS_BASE_URL nor the localhost tail is consulted and nothing parses. The
// old banner printed http://localhost:3000 here with total confidence.
process.env.OS_AUTH_URL = '';
process.env.OS_BASE_URL = 'https://never-consulted.example.com';

const banner = bootBanner(3000);

expect(banner).toContain('/api/v1/mcp');
expect(banner).not.toContain('http://localhost:3000');
expect(banner).not.toContain('never-consulted');
expect(banner).toContain('OS_AUTH_URL');
});

it('prints paths only when the configured base URL has no scheme', () => {
process.env.OS_AUTH_URL = 'app.example.com';

const banner = bootBanner(3000);

expect(banner).toContain('/api/v1/mcp');
expect(banner).not.toContain('http://localhost:3000');
// The bare hostname must not be smuggled in as an origin either.
expect(banner).not.toContain('app.example.com/api/v1/mcp');
});
});
23 changes: 22 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3436,7 +3436,28 @@ export default class Serve extends Command {
// #8978 — the Config:/Artifact: row must name what actually booted,
// never `relativeConfig` unconditionally (see resolveBannerConfigRow).
printServerReady({
port,
// #10646 — the banner used to take `port` and compose
// `http://localhost:<port>` itself, which is where this process
// LISTENS, not where an operator can reach it. On the EE 4.1.0 compose
// stack that address is `expose`-only (no `ports:` mapping, and less
// reachable still under `--scale app=N`) while Caddy publishes `:80`
// and `OS_AUTH_URL` is already `http://localhost` — so the Console link
// 404'd at the shell and the `MCP:` line customers paste into an AI
// client could never connect.
//
// Resolved through the runtime's OWN chain, not a banner-local copy of
// it: `resolveAuthBaseUrl` is the same function whose `baseOrigin` is
// pushed onto the CSRF allow-list a few hundred lines above, so the
// banner cannot disagree with what the deployment actually trusts. It
// reads only `process.env` and the port, so calling it here changes
// nothing about what is bound or advertised — this is printed text.
//
// `port` is the port the server ACTUALLY bound (past any dev auto-shift),
// so the `http://localhost:<port>` tail of the chain still names the
// right address in the local dev loop. `baseOrigin` is `null` when the
// chain produced something unparseable; the banner then prints paths
// with no origin rather than a confident wrong URL.
externalBaseOrigin: resolveAuthBaseUrl(port).baseOrigin,
...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }),
isDev,
pluginCount: loadedPlugins.length,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/format.config-artifact-row.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,7 @@ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');

describe('printServerReady Config:/Artifact: row (#8978)', () => {
const base: Omit<ServerReadyOptions, 'configFile' | 'artifactSource'> = {
port: 3000,
externalBaseOrigin: 'http://localhost:3000',
isDev: true,
pluginCount: 1,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/format.seed-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ import { printServerReady, type ServerReadyOptions, type SeedSourceSummary } fro
*/
describe('printServerReady seed summary (#3415/#3430)', () => {
const base: ServerReadyOptions = {
port: 3000,
externalBaseOrigin: 'http://localhost:3000',
configFile: 'objectstack.config.ts',
isDev: true,
pluginCount: 1,
Expand Down
164 changes: 164 additions & 0 deletions packages/cli/src/utils/format.server-ready-base-url.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { printServerReady, type ServerReadyOptions } from './format.js';

/**
* framework#10646 — the ready banner's API / Console / MCP links.
*
* The banner used to take a `port` and build `http://localhost:${port}` itself.
* That is where the process LISTENS, which stops being where a human can reach
* it the moment anything sits in front of it. Measured on the EE 4.1.0 compose
* stack (moved from cloud#1507): the app container `expose`s `:3000` with no
* `ports:` mapping — unreachable from the host, and less so still under
* `--scale app=N` — while the published entry point is Caddy on `:80` and
* `OS_AUTH_URL` is already resolved to `http://localhost`. The banner printed
*
* API: http://localhost:3000/
* Console: http://localhost:3000/_console/
* MCP: http://localhost:3000/api/v1/mcp
*
* so the Console link failed outright, and the `MCP:` line — the one customers
* paste into an AI client — named an address that can never connect and never
* says so.
*
* The property under test is therefore not "the URL looks right", it is
* **every absolute URL in the banner is the origin the caller resolved, and
* when no origin could be resolved the banner prints no absolute URL at all**.
* The second half is the interesting one: a missing address sends the operator
* to look one up, a confident wrong one gets copied.
*/
describe('printServerReady links (#10646)', () => {
const base: Omit<ServerReadyOptions, 'externalBaseOrigin'> = {
configFile: 'objectstack.config.ts',
isDev: false,
pluginCount: 1,
uiEnabled: true,
consolePath: '/_console',
mcpEnabled: true,
};

let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
lines = [];
// stderr, not stdout (#7915) — the whole banner is a diagnostic. SGR
// escapes stripped so the assertions hold whether or not chalk colors.
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' ').replace(/\u001b\[[0-9;]*m/g, ''));
});
});

afterEach(() => spy.mockRestore());

/** The one banner line whose label matches, ANSI already stripped. */
const row = (label: string): string =>
lines.find((l) => l.includes(`${label}:`)) ?? '';

/** The dim MCP hint — the line customers copy the skill URL out of. */
const skillRow = (): string => lines.find((l) => l.includes('skill:')) ?? '';

describe('a resolved external base is what gets printed', () => {
it('prints the EE compose stack published origin, not the bound port', () => {
// The #10646 repro verbatim: Caddy on :80, app bound to :3000 inside.
printServerReady({ ...base, externalBaseOrigin: 'http://localhost' });

expect(row('API')).toContain('http://localhost/');
expect(row('Console')).toContain('http://localhost/_console/');
expect(row('MCP')).toContain('http://localhost/api/v1/mcp');
expect(skillRow()).toContain('http://localhost/api/v1/mcp/skill');
// The whole defect in one assertion: the internal port must not appear.
expect(lines.join('\n')).not.toContain(':3000');
});

it('follows the deployment onto a real domain (the README HTTPS step)', () => {
printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com' });

expect(row('API')).toContain('https://app.example.com/');
expect(row('Console')).toContain('https://app.example.com/_console/');
expect(row('MCP')).toContain('https://app.example.com/api/v1/mcp');
expect(skillRow()).toContain('https://app.example.com/api/v1/mcp/skill');
expect(lines.join('\n')).not.toContain('localhost');
});

it('keeps a non-default port when the reachable origin carries one', () => {
printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com:8443' });
expect(row('MCP')).toContain('https://app.example.com:8443/api/v1/mcp');
});

it('leaves the local dev loop exactly as it was', () => {
// The tail of the runtime's chain is still `http://localhost:<port>`, and
// on a laptop that IS the reachable address. Guards the fix against
// over-reach: `os dev` must keep its clickable Console link.
printServerReady({ ...base, isDev: true, externalBaseOrigin: 'http://localhost:3001' });

expect(row('API')).toContain('http://localhost:3001/');
expect(row('Console')).toContain('http://localhost:3001/_console/');
expect(row('MCP')).toContain('http://localhost:3001/api/v1/mcp');
});
});

describe('an unresolvable base prints NO absolute URL', () => {
/** Any absolute URL that is not the one this file's own hint text cites. */
const guessedUrl = /https?:\/\/(?!app\.example\.com\b)/;

it('prints paths only — never a fabricated origin', () => {
printServerReady({ ...base, externalBaseOrigin: null });

const banner = lines.join('\n');
// The load-bearing assertion. Not "does not contain localhost" — ANY
// absolute URL here would be a guess, and the guess is the defect. The
// one exemption is the hint's own `https://app.example.com` placeholder,
// which is explicitly an example and not a link to this deployment.
expect(banner).not.toMatch(guessedUrl);
expect(banner).not.toContain('localhost');
});

it('still names the paths, so the operator keeps the information', () => {
// "No absolute URL" is not "no line". The operator must still learn that
// MCP is mounted and where — they supply the origin they actually use.
printServerReady({ ...base, externalBaseOrigin: null });

expect(row('API').trim()).toMatch(/API:\s+\/$/);
expect(row('Console').trim()).toMatch(/Console:\s+\/_console\/$/);
expect(row('MCP').trim()).toMatch(/MCP:\s+\/api\/v1\/mcp$/);
expect(skillRow()).toContain('skill: /api/v1/mcp/skill');
});

it('says why the origin is missing and names the variable that fixes it', () => {
printServerReady({ ...base, externalBaseOrigin: null });

const banner = lines.join('\n');
expect(banner).toContain('external base URL could not be resolved');
expect(banner).toContain('OS_AUTH_URL');
});

it('does not print the hint when the base IS resolved', () => {
printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com' });
expect(lines.join('\n')).not.toContain('could not be resolved');
});

it('drops the origin from the MCP lines even when the Console is off', () => {
// The MCP line is the paste target; it must not keep an origin of its own
// on any boot shape.
printServerReady({ ...base, uiEnabled: false, consolePath: undefined, externalBaseOrigin: null });

expect(row('Console')).toBe('');
expect(row('MCP').trim()).toMatch(/MCP:\s+\/api\/v1\/mcp$/);
expect(skillRow()).toContain('skill: /api/v1/mcp/skill');
expect(lines.join('\n')).not.toMatch(guessedUrl);
});
});

it('cannot compose an address from a port at COMPILE time', () => {
// The structural half of the fix, checked by `pnpm typecheck` (tests are
// type-checked — AGENTS.md) rather than by the assertion below: `port` is
// gone from the options, so re-deriving `http://localhost:<port>` inside
// the banner is no longer expressible, and a caller that forgets to resolve
// an origin fails to compile instead of silently getting localhost back.
// @ts-expect-error — `port` was removed with #10646; nothing reads it.
printServerReady({ ...base, externalBaseOrigin: null, port: 3000 });
expect(lines.some((l) => l.includes('Server is ready'))).toBe(true);
});
});
Loading
Loading