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
12 changes: 10 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
26 changes: 19 additions & 7 deletions examples/app-showcase/src/system/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
*
Expand Down Expand Up @@ -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' },
});
Expand Down Expand Up @@ -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' },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
67 changes: 67 additions & 0 deletions examples/app-showcase/src/system/self-url.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> };

/** 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<string, string | undefined> = 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}`;
}
132 changes: 132 additions & 0 deletions examples/app-showcase/test/connector-self-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> };

/** Load the connector metadata fresh under a given environment. */
async function connectorsUnderEnv(env: Record<string, string | undefined>) {
vi.resetModules();
const saved: Record<string, string | undefined> = {};
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<string, unknown> }): 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<string, unknown> }>)
.filter((c) => c.providerConfig?.baseUrl === 'http://127.0.0.1:3000')
.map((c) => c.name);
expect(hardWired).toEqual([]);
});
});
Loading