diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 14bceaa8d0..41abf04899 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -35,6 +35,7 @@ import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; import { allApis } from './src/system/apis/index.js'; import { allConnectors } from './src/system/connectors/index.js'; +import { resolveShowcaseSelfUrl } from './src/system/self-url.js'; import { allPositions, allPermissionSets, @@ -110,7 +111,11 @@ export default defineStack({ // ships the dispatch node + an empty registry; these plugins populate it. // • rest → points at the running server itself, so the REST connector // flow's call + response are observable on the flow run with no - // external dependency. Override the target with SHOWCASE_SELF_URL. + // external dependency. The target is resolved by + // src/system/self-url.ts — SHOWCASE_SELF_URL, else the CLI's + // own OS_PORT / PORT, else http://127.0.0.1:3000 — and the + // declarative connector instances in src/system/connectors/ + // resolve through the SAME helper (#7538). // • slack → registered so TaskCompletedSlackFlow resolves its connector; // live posting needs a real bot token (set SLACK_BOT_TOKEN). // • openapi → option-less: contributes only the `openapi` provider factory @@ -132,7 +137,10 @@ export default defineStack({ new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), new ConnectorRestPlugin({ name: 'rest', - baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000', + // Shared with the declarative connector instances in + // src/system/connectors/ (#7538) — one resolver, so the two self-URL + // sources cannot drift apart. + baseUrl: resolveShowcaseSelfUrl(), }), new ConnectorSlackPlugin({ token: process.env.SLACK_BOT_TOKEN ?? 'xoxb-showcase-demo-token', diff --git a/examples/app-showcase/src/system/connectors/index.ts b/examples/app-showcase/src/system/connectors/index.ts index f97ccc20d5..1190544ca3 100644 --- a/examples/app-showcase/src/system/connectors/index.ts +++ b/examples/app-showcase/src/system/connectors/index.ts @@ -2,6 +2,8 @@ import { defineConnector, type Connector } from '@objectstack/spec/integration'; +import { resolveShowcaseSelfUrl } from '../self-url.js'; + /** * Declarative `connectors:` — the collection now holds BOTH kinds (ADR-0097): * @@ -50,11 +52,16 @@ export const StatusApiConnector = defineConnector({ 'connector_action and appears in GET /connectors.', provider: 'rest', providerConfig: { - // Points at the running server itself (the showcase dev port is 3000), so - // the dispatch is observable with no external dependency. Kept a literal - // because metadata files don't read env — the env-driven `rest` plugin - // connector in objectstack.config.ts is the tunable one. - baseUrl: 'http://127.0.0.1:3000', + // Points at the running server itself, so the dispatch is observable with + // no external dependency. Resolved from the environment (#7538) via the + // same helper objectstack.config.ts's `rest` plugin uses, so the two + // self-URL sources cannot diverge: SHOWCASE_SELF_URL, else the CLI's own + // OS_PORT / PORT, else http://127.0.0.1:3000. A literal here made every + // self-ping flow fail `fetch failed` on any instance not listening on 3000 + // — and metadata modules DO read env: this file is evaluated by whichever + // process loads objectstack.config.ts (see ../self-url.ts for when that is + // boot time vs build time). + baseUrl: resolveShowcaseSelfUrl(), }, auth: { type: 'none' }, }); @@ -85,8 +92,13 @@ export const StatusOpenApiConnector = defineConnector({ // holds objectstack.config.ts (the CLI passes it as the automation // service's packageRoot). Inline documents and http(s) URLs stay valid. spec: './src/system/connectors/status-openapi.json', - // Same self-pointing literal rationale as StatusApiConnector above. - baseUrl: 'http://127.0.0.1:3000', + // Same env-resolved self URL as StatusApiConnector above (#7538). This + // OVERRIDES the document's own `servers[0].url` — createOpenApiConnector + // resolves `config.baseUrl ?? document.servers?.[0]?.url` + // (packages/connectors/connector-openapi/src/openapi-connector.ts) — so the + // static literal in status-openapi.json stays a documentation default and + // is not what the dispatch actually uses. + baseUrl: resolveShowcaseSelfUrl(), }, auth: { type: 'none' }, }); diff --git a/examples/app-showcase/src/system/connectors/status-openapi.json b/examples/app-showcase/src/system/connectors/status-openapi.json index 2e8d11600a..fbdccbb1c0 100644 --- a/examples/app-showcase/src/system/connectors/status-openapi.json +++ b/examples/app-showcase/src/system/connectors/status-openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Showcase Status API", "version": "1.0.0", - "description": "Minimal OpenAPI document for the showcase's own health probe. Referenced by the StatusOpenApiConnector declarative instance (src/system/connectors/index.ts) as a package-relative file path — the #3016 / ADR-0096 spec form resolved and confined to this package's root at boot." + "description": "Minimal OpenAPI document for the showcase's own health probe. Referenced by the StatusOpenApiConnector declarative instance (src/system/connectors/index.ts) as a package-relative file path — the #3016 / ADR-0096 spec form resolved and confined to this package's root at boot. NOTE (#7538): a static document cannot follow the port this instance actually bound, so `servers[0].url` below is only the documentation default. The connector supplies an env-resolved `providerConfig.baseUrl` (src/system/self-url.ts), and createOpenApiConnector resolves `config.baseUrl ?? document.servers[0].url` — so the value below is overridden on every dispatch." }, "servers": [{ "url": "http://127.0.0.1:3000" }], "paths": { diff --git a/examples/app-showcase/src/system/self-url.ts b/examples/app-showcase/src/system/self-url.ts new file mode 100644 index 0000000000..7e17c36867 --- /dev/null +++ b/examples/app-showcase/src/system/self-url.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The showcase's **single source of truth for its own base URL** (#7538). + * + * Several showcase surfaces point at the running server itself so their demo is + * observable with no external dependency: the hand-wired `rest` connector + * plugin in objectstack.config.ts, and the two declarative REST/OpenAPI + * connector instances in `src/system/connectors/`. Before #7538 the plugin read + * `SHOWCASE_SELF_URL` while the declarative instances carried the literal + * `http://127.0.0.1:3000` — so on any instance NOT listening on 3000 (CI, QA, + * any dev boot on an isolated port) every flow dispatching through those + * connectors failed with `fetch failed`. That failure is indistinguishable from + * a sandbox egress block, which is what made it expensive: the QA run in #7516 + * only proved it was an address problem by putting a TCP forwarder on 3000. + * + * Resolution order — most explicit first: + * + * 1. `SHOWCASE_SELF_URL` — a full base URL. The escape hatch for anything the + * port alone cannot express (a different host/interface, https, a proxy + * prefix). Kept as the primary knob because objectstack.config.ts already + * documented it. + * 2. `OS_PORT`, then its deprecated alias `PORT` — **the same names, in the + * same order, that the CLI itself reads to choose the listen port** + * (`packages/cli/src/commands/serve.ts`: `readEnvWithDeprecation('OS_PORT', + * 'PORT') ?? '3000'`). Following the CLI's own inputs is what makes the + * isolated-port boot self-ping correctly with no extra configuration — the + * exact case #7538 was filed for. + * 3. `http://127.0.0.1:3000` — the historical literal, unchanged, so a plain + * `pnpm dev` behaves exactly as before. + * + * **When this is read matters.** These are ordinary Node modules evaluated at + * config load, so the read happens in whichever process loads + * objectstack.config.ts. On the `os dev` / `os serve` path that is the serving + * process itself, so the value follows the live environment. On the + * artifact-only path (`os build` once, then `os start --artifact`) the + * connector metadata is serialized into `dist/objectstack.json`, so the value + * is frozen at BUILD time — set the env for the build, not just the boot. + * (The plugin in `plugins:` is code and cannot be serialized at all, so it only + * exists on the config-load path.) + */ + +// Ambient `process` for the env reads below — the showcase tsconfig doesn't +// pull in `@types/node`, but the CLI provides the real `process` at runtime. +// Same idiom (and same reason) as the declaration in objectstack.config.ts: +// keeps `pnpm typecheck` green without widening the type surface. +declare const process: { env: Record }; + +/** The listen port the CLI defaults to when neither `OS_PORT` nor `PORT` is set. */ +export const SHOWCASE_DEFAULT_PORT = '3000'; + +/** The base URL used when nothing in the environment says otherwise. */ +export const SHOWCASE_DEFAULT_SELF_URL = `http://127.0.0.1:${SHOWCASE_DEFAULT_PORT}`; + +/** + * Resolve the base URL at which this showcase instance can reach itself. + * + * @param env - Environment to read. Defaults to `process.env`; injectable so + * tests can assert each precedence rung without mutating the real process. + */ +export function resolveShowcaseSelfUrl(env: Record = process.env): string { + const explicit = env.SHOWCASE_SELF_URL?.trim(); + if (explicit) return explicit.replace(/\/+$/, ''); + + const port = env.OS_PORT?.trim() || env.PORT?.trim() || SHOWCASE_DEFAULT_PORT; + return `http://127.0.0.1:${port}`; +} diff --git a/examples/app-showcase/test/connector-self-url.test.ts b/examples/app-showcase/test/connector-self-url.test.ts new file mode 100644 index 0000000000..d4bdd24431 --- /dev/null +++ b/examples/app-showcase/test/connector-self-url.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + resolveShowcaseSelfUrl, + SHOWCASE_DEFAULT_SELF_URL, + SHOWCASE_DEFAULT_PORT, +} from '../src/system/self-url.js'; + +/** + * #7538 — the showcase's self-pointing connectors must follow the port the + * instance actually bound. + * + * Before the fix, `StatusApiConnector` and `StatusOpenApiConnector` carried the + * literal `http://127.0.0.1:3000` in `providerConfig`, so every flow that + * dispatched through them failed with `fetch failed` on any instance not + * listening on 3000 — CI, QA, any dev boot on an isolated port. The failure is + * indistinguishable from a sandbox egress block, which is what made it + * expensive to diagnose (the QA run in #7516 needed a TCP forwarder on 3000 to + * prove the address was the whole problem). + * + * The guards below pin both halves of the contract the fix must hold: + * a non-3000 environment moves the connectors' `baseUrl`, and an empty + * environment still resolves to the historical literal. + * + * Reverse verification (expected direction: RED on revert). Restoring the + * literal at src/system/connectors/index.ts:57 / :89 turns the two + * "follows the environment" cases below red while the two "defaults" cases stay + * green — a literal is, by construction, still correct in the default case. + * That asymmetry is the point: the default-case assertions alone can never + * detect the bug, so both halves are required. + */ + +// Ambient `process` with `env` — test/node-shim.d.ts declares the global as +// `{ cwd(): string }` only, and this module-scoped declaration shadows it +// rather than widening the shared shim (the same idiom objectstack.config.ts +// uses for its own env reads). +declare const process: { env: Record }; + +/** Load the connector metadata fresh under a given environment. */ +async function connectorsUnderEnv(env: Record) { + vi.resetModules(); + const saved: Record = {}; + for (const key of ['SHOWCASE_SELF_URL', 'OS_PORT', 'PORT']) { + saved[key] = process.env[key]; + if (env[key] === undefined) delete process.env[key]; + else process.env[key] = env[key]; + } + try { + return await import('../src/system/connectors/index.js'); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function baseUrlOf(connector: { providerConfig?: Record }): unknown { + return connector.providerConfig?.baseUrl; +} + +afterEach(() => { + vi.resetModules(); +}); + +describe('resolveShowcaseSelfUrl precedence (#7538)', () => { + it('prefers an explicit SHOWCASE_SELF_URL over any port', () => { + expect(resolveShowcaseSelfUrl({ SHOWCASE_SELF_URL: 'https://showcase.internal', OS_PORT: '4711' })) + .toBe('https://showcase.internal'); + }); + + it('trims a trailing slash so callers can join paths without doubling it', () => { + expect(resolveShowcaseSelfUrl({ SHOWCASE_SELF_URL: 'http://127.0.0.1:8080/' })) + .toBe('http://127.0.0.1:8080'); + }); + + it("follows the CLI's own OS_PORT when no explicit URL is set", () => { + expect(resolveShowcaseSelfUrl({ OS_PORT: '4711' })).toBe('http://127.0.0.1:4711'); + }); + + it("follows the CLI's deprecated PORT alias when OS_PORT is absent", () => { + expect(resolveShowcaseSelfUrl({ PORT: '5822' })).toBe('http://127.0.0.1:5822'); + }); + + it('prefers OS_PORT over PORT — the same order readEnvWithDeprecation uses', () => { + expect(resolveShowcaseSelfUrl({ OS_PORT: '4711', PORT: '5822' })).toBe('http://127.0.0.1:4711'); + }); + + it('falls back to the historical literal on an empty environment', () => { + expect(resolveShowcaseSelfUrl({})).toBe('http://127.0.0.1:3000'); + expect(SHOWCASE_DEFAULT_SELF_URL).toBe('http://127.0.0.1:3000'); + expect(SHOWCASE_DEFAULT_PORT).toBe('3000'); + }); + + it('ignores a blank value rather than resolving to a broken URL', () => { + expect(resolveShowcaseSelfUrl({ SHOWCASE_SELF_URL: ' ', OS_PORT: ' ' })) + .toBe('http://127.0.0.1:3000'); + }); +}); + +describe('self-pointing connector instances follow the environment (#7538)', () => { + it('StatusApiConnector resolves against a non-3000 port', async () => { + const mod = await connectorsUnderEnv({ OS_PORT: '4711' }); + expect(baseUrlOf(mod.StatusApiConnector)).toBe('http://127.0.0.1:4711'); + }); + + it('StatusOpenApiConnector resolves against a non-3000 port', async () => { + const mod = await connectorsUnderEnv({ OS_PORT: '4711' }); + expect(baseUrlOf(mod.StatusOpenApiConnector)).toBe('http://127.0.0.1:4711'); + }); + + it('both instances honour an explicit SHOWCASE_SELF_URL', async () => { + const mod = await connectorsUnderEnv({ SHOWCASE_SELF_URL: 'http://127.0.0.1:8123' }); + expect(baseUrlOf(mod.StatusApiConnector)).toBe('http://127.0.0.1:8123'); + expect(baseUrlOf(mod.StatusOpenApiConnector)).toBe('http://127.0.0.1:8123'); + }); + + it('both instances still default to 127.0.0.1:3000 with nothing set', async () => { + const mod = await connectorsUnderEnv({}); + expect(baseUrlOf(mod.StatusApiConnector)).toBe('http://127.0.0.1:3000'); + expect(baseUrlOf(mod.StatusOpenApiConnector)).toBe('http://127.0.0.1:3000'); + }); + + it('no connector carries a hard-wired self URL any more', async () => { + const mod = await connectorsUnderEnv({ OS_PORT: '4711' }); + const hardWired = (mod.allConnectors as Array<{ name: string; providerConfig?: Record }>) + .filter((c) => c.providerConfig?.baseUrl === 'http://127.0.0.1:3000') + .map((c) => c.name); + expect(hardWired).toEqual([]); + }); +});