Skip to content

Commit 2b17dec

Browse files
committed
feat(cli): terminate TLS in os dev from a developer-supplied cert, and derive the canonical origin from the listener
`objectstack dev --cert <path> --key <path>` terminates TLS in the dev process itself, and every origin the boot advertises follows it: the two `/.well-known/*` documents, the CSRF allow-list, the ready banner's `API:` row, the `MCP server` connect hint and the runtime state file. The developer brings the certificate. Nothing here generates one, and nothing here says anything about installing one into a trust store. - `utils/dev-tls-contract.ts` — the one reader of the flag pair, shared by `dev` and the `serve` child it spawns, so the protocol the parent derives and the protocol the child binds come from one answer. Half a pair is refused by the door the operator typed at; an unreadable file is refused by the process that would have bound the socket, and never degraded to a plain-http listener. - `resolveAuthBaseUrl(port, boundProtocol)` — only the built-in default tail follows the listener. `OS_AUTH_URL` and the rest of the configured chain keep winning: they name where a deployment is reached, not what this process bound. The parameter defaults to `http`, so a tree with no TLS flags in play resolves byte-for-byte as before. - `publishBoundPort(..., boundProtocol)` — the socket's own address, which both the runtime state file and the `objectstack:listening` IPC message send a consumer to. - `HonoPluginOptions.tls` / `HonoHttpServer` — `@hono/node-server` takes a listener factory as an option, so the TLS arm is the same fetch handler and the same drain with one different server factory. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fa2a8a commit 2b17dec

5 files changed

Lines changed: 463 additions & 20 deletions

File tree

‎packages/cli/src/commands/dev.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
2424
// no range, no reader, no wording; a second copy of the bound is exactly what
2525
// #12620 and #12662 protected against.
2626
import { describePortSource, parseRequestedPort, formatInvalidPortNotice } from '../utils/port-contract.js';
27+
// The ONE dev-TLS contract, shared with the `serve` child this command spawns
28+
// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no
29+
// certificate is ever generated — the developer brings their own.
30+
import {
31+
devTlsCertFlag,
32+
devTlsKeyFlag,
33+
resolveDevTlsIntent,
34+
listenerProtocol,
35+
devTlsChildArgs,
36+
colorizeDevTlsNotice,
37+
type ListenerProtocol,
38+
} from '../utils/dev-tls-contract.js';
2739
// The auth base-URL precedence chain, borrowed from the command that owns it
2840
// (#16734). ⛔ `dev` declares no chain of its own — see printMcpConnectHint.
2941
import { resolveAuthBaseUrl } from './serve.js';
@@ -106,8 +118,10 @@ export async function resolveDevDatabase(opts: {
106118
* here is no block at all: falling back to the bound socket would reprint, on
107119
* the same screen, the exact address the banner just refused to print.
108120
*/
109-
export function printMcpConnectHint(opts: { boundPort: number | string; name: string }): void {
110-
const { baseOrigin } = resolveAuthBaseUrl(opts.boundPort);
121+
export function printMcpConnectHint(
122+
opts: { boundPort: number | string; name: string; boundProtocol?: ListenerProtocol },
123+
): void {
124+
const { baseOrigin } = resolveAuthBaseUrl(opts.boundPort, opts.boundProtocol ?? 'http');
111125
if (baseOrigin === null) return;
112126
console.log();
113127
console.log(chalk.cyan(' 🤖 MCP server — connect a coding agent:'));
@@ -134,6 +148,11 @@ export default class Dev extends Command {
134148
options: ['debug', 'info', 'warn', 'error', 'fatal', 'silent'],
135149
}),
136150
port: Flags.string({ char: 'p', description: 'Server port (overrides $PORT)' }),
151+
// #16804 — developer-supplied TLS, forwarded to the `serve` child. Declared
152+
// through the shared contract so the two commands cannot drift on the flag
153+
// names, the prose, or what half a pair means.
154+
cert: devTlsCertFlag(),
155+
key: devTlsKeyFlag(),
137156
preset: Flags.string({
138157
description: 'Plugin tier preset forwarded to `serve`: minimal | default | full',
139158
}),
@@ -211,6 +230,19 @@ export default class Dev extends Command {
211230
const { args, flags } = await this.parse(Dev);
212231
const packageName = args.package;
213232

233+
// ── The TLS pair, read ONCE, ahead of every child and every printer ──
234+
// Refused here rather than one process later so half a pair is named under
235+
// the spelling the operator typed, and resolved before the connect hint
236+
// below so the parent's derived origin and the child's bound socket come
237+
// from the same answer (#16804). ⛔ This reads no certificate — the child
238+
// binds the socket, so the child owns the refusal for an unusable file.
239+
const tlsIntent = resolveDevTlsIntent(flags);
240+
if (tlsIntent.kind === 'incomplete') {
241+
console.error(colorizeDevTlsNotice(tlsIntent.notice));
242+
process.exit(1);
243+
}
244+
const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent);
245+
214246
// Load .env files following Vite/Next.js convention (mirrors `serve`).
215247
// `dev` is always development mode, so prefer `.env.development*` over
216248
// `.env.production*`. Loaded BEFORE any env lookups.
@@ -514,6 +546,10 @@ export default class Dev extends Command {
514546
'serve',
515547
'--dev',
516548
...(port ? ['--port', port] : []),
549+
// The PATHS, not the bytes: the child binds the socket, so it is
550+
// the process that must fail when the certificate is unusable
551+
// (#16804). One reader of the file, one owner of that refusal.
552+
...devTlsChildArgs(tlsIntent),
517553
...(flags.ui ? ['--ui'] : []),
518554
...(flags.verbose ? ['--verbose'] : []),
519555
...(flags['log-level'] ? ['--log-level', flags['log-level']] : []),
@@ -555,7 +591,14 @@ export default class Dev extends Command {
555591
// origin these lines carry is resolved from the runtime's own
556592
// precedence chain inside the printer, so the banner the child
557593
// prints and this block cannot name two different deployments.
558-
printMcpConnectHint({ boundPort: actual, name: path.basename(process.cwd()) || 'objectstack' });
594+
printMcpConnectHint({
595+
boundPort: actual,
596+
name: path.basename(process.cwd()) || 'objectstack',
597+
// ⭐ From THIS process's own flags, which are the same flags it
598+
// forwarded to the child — so the scheme the hint prints and the
599+
// scheme the child bound cannot part company (#16804).
600+
boundProtocol,
601+
});
559602
}
560603
}
561604
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ import {
6262
MAX_PORT,
6363
type PortInputSource,
6464
} from '../utils/port-contract.js';
65+
// The ONE dev-TLS contract, shared with `dev` (which spawns this command) so the
66+
// protocol the parent derives and the protocol this process actually binds come
67+
// from a single reader (#16804). ⛔ Nothing about certificates is declared in
68+
// this file, and ⛔ nothing anywhere generates one — see that module's header.
69+
import {
70+
devTlsCertFlag,
71+
devTlsKeyFlag,
72+
resolveDevTlsIntent,
73+
readDevTlsMaterial,
74+
listenerProtocol,
75+
colorizeDevTlsNotice,
76+
type DevTlsMaterial,
77+
type ListenerProtocol,
78+
} from '../utils/dev-tls-contract.js';
6579
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
6680
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
6781
// [ADR-0130 D4 / option B, #15006] Every read below that keys off a
@@ -542,8 +556,21 @@ export interface BoundPortChannels {
542556
* it can only lose the race often enough for someone to notice — which is
543557
* precisely the year-of-flakes this replaces.
544558
*/
545-
export function publishBoundPort(boundPort: number, channels: BoundPortChannels): void {
546-
const url = `http://localhost:${boundPort}`;
559+
export function publishBoundPort(
560+
boundPort: number,
561+
channels: BoundPortChannels,
562+
boundProtocol: ListenerProtocol = 'http',
563+
): void {
564+
// ⭐ The SOCKET's own address, so the scheme has to be the one the socket
565+
// actually speaks (#16804). Both consumers of this `url` open it: the runtime
566+
// state file is what an external supervisor or health check dials, and the
567+
// IPC message is what the `os dev` parent learns the server from. Under
568+
// `--cert`/`--key` a hardcoded `http://` here would hand both of them an
569+
// address that answers a TLS handshake error — a machine-readable surface
570+
// saying something untrue about the process that wrote it. ⛔ Unlike the
571+
// canonical origin, this is NOT overridable by `OS_AUTH_URL`: that variable
572+
// names where a deployment is REACHED, and this line names what was BOUND.
573+
const url = `${boundProtocol}://localhost:${boundPort}`;
547574
// 1 ─ THE FILE FIRST. Both announcements below send a consumer to it.
548575
channels.writeRuntimeState({ port: boundPort, url });
549576
// 2 ─ IPC: the `os dev` parent learns the real port without polling.
@@ -947,6 +974,11 @@ export default class Serve extends Command {
947974
options: [...LOG_LEVELS],
948975
}),
949976
verbose: Flags.boolean({ char: 'v', description: 'Verbose output — shortcut for --log-level debug.' }),
977+
// #16804 — developer-supplied TLS, declared through the shared contract so
978+
// `dev` (which spawns this command) and `serve` cannot drift on the flag
979+
// names, the prose, or what half a pair means.
980+
cert: devTlsCertFlag(),
981+
key: devTlsKeyFlag(),
950982
};
951983

952984
/**
@@ -1879,6 +1911,38 @@ export default class Serve extends Command {
18791911
}
18801912
const requestedPort = parsedPort;
18811913

1914+
// ── The TLS pair, refused here for the same reason the port is (#16804) ──
1915+
// Ahead of every socket and every import that branches on the protocol, and
1916+
// BEFORE `resolveAuthBaseUrl` is consulted anywhere: the canonical origin's
1917+
// fallback tail is derived from this answer, so a pair that will not be
1918+
// honoured must never reach the resolver as if it would be.
1919+
const tlsIntent = resolveDevTlsIntent(flags);
1920+
if (tlsIntent.kind === 'incomplete') {
1921+
printDiagnostic(colorizeDevTlsNotice(tlsIntent.notice));
1922+
this.exit(1);
1923+
}
1924+
/**
1925+
* The scheme this process will actually speak — `https` only when a
1926+
* certificate and a key were BOTH given. Every origin this boot advertises
1927+
* is built from it, and there is no other way to set it.
1928+
*/
1929+
const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent);
1930+
/**
1931+
* PEM bytes for the listener, read HERE so an unreadable certificate is
1932+
* refused by the process that would have bound the socket, naming the flag
1933+
* and the path — ⛔ never degraded to a plain-http listener (see
1934+
* `formatUnreadableDevTlsFileNotice`).
1935+
*/
1936+
let tlsMaterial: DevTlsMaterial | undefined;
1937+
if (tlsIntent.kind === 'requested') {
1938+
try {
1939+
tlsMaterial = readDevTlsMaterial(tlsIntent);
1940+
} catch (e: any) {
1941+
printDiagnostic(colorizeDevTlsNotice(String(e?.message ?? e)));
1942+
this.exit(1);
1943+
}
1944+
}
1945+
18821946
// ── …and it has to BE the port the text SAYS, or say otherwise (#12674) ──
18831947
// `parseInt` is kept as the reader (#12662's ruling: nothing that boots
18841948
// today may be refused), and its tolerance changes the answer rather than
@@ -3242,7 +3306,13 @@ export default class Serve extends Command {
32423306
if (flags.server && !configHasHonoServer) {
32433307
try {
32443308
const { HonoServerPlugin } = await import('@objectstack/plugin-hono-server');
3245-
const serverPlugin = new HonoServerPlugin({ port });
3309+
// `tls` is `undefined` unless --cert/--key were both given and both
3310+
// files read (#16804) — the adapter then binds a TLS listener with
3311+
// the same fetch handler. ⛔ Nothing here generates a certificate.
3312+
const serverPlugin = new HonoServerPlugin({
3313+
port,
3314+
tls: tlsMaterial ? { cert: tlsMaterial.cert, key: tlsMaterial.key } : undefined,
3315+
});
32463316
await kernel.use(serverPlugin);
32473317
trackPlugin('HonoServer');
32483318
} catch (e: any) {
@@ -3567,7 +3637,7 @@ export default class Serve extends Command {
35673637
// additionally reports where the value came from and whether it
35683638
// parses, so an unusable one can be said out loud instead of
35693639
// vanishing into an empty catch (#10202).
3570-
const baseUrlResolution = resolveAuthBaseUrl(port);
3640+
const baseUrlResolution = resolveAuthBaseUrl(port, boundProtocol);
35713641
const baseUrl = baseUrlResolution.value;
35723642

35733643
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {};
@@ -4851,7 +4921,7 @@ export default class Serve extends Command {
48514921
// the bound one. `baseOrigin` is `null` when the chain produced
48524922
// something unparseable; the banner then prints paths with no origin
48534923
// rather than a confident wrong URL.
4854-
externalBaseOrigin: resolveAuthBaseUrl(boundPort).baseOrigin,
4924+
externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin,
48554925
...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }),
48564926
isDev,
48574927
pluginCount: loadedPlugins.length,
@@ -4914,7 +4984,7 @@ export default class Serve extends Command {
49144984
// the file is written BEFORE either channel announces the address that
49154985
// sends a consumer to it. {@link publishBoundPort} carries the race the
49164986
// old order lost, and the reason the repair is not reader-side polling.
4917-
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner));
4987+
publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol);
49184988

49194989
// ── Watch the served database file's identity ──────────────────
49204990
// Deleting the data directory under a running server (`rm -rf
@@ -5630,11 +5700,35 @@ export interface AuthBaseUrlResolution {
56305700
* `baseOrigin` is spelled `${protocol}//${host}` rather than `URL.origin`
56315701
* because that is what the inline code computed, and the two disagree for
56325702
* non-special schemes (`URL.origin` answers the string `"null"`).
5703+
*
5704+
* ## The tail follows the LISTENER, and only the tail (#16804)
5705+
*
5706+
* `boundProtocol` names the scheme this process actually bound — `https`
5707+
* exactly when `--cert`/`--key` were both given, derived in `dev-tls-contract`
5708+
* from that material and settable nowhere else. It reaches only the built-in
5709+
* default at the end of the chain, because that default is the one link nobody
5710+
* configured: it is this process describing its own socket, and once TLS
5711+
* terminates in-process `http://localhost:<port>` is an address no client can
5712+
* reach.
5713+
*
5714+
* ⛔ Every CONFIGURED link is untouched. `OS_AUTH_URL`, its legacy name and
5715+
* `OS_BASE_URL` answer a different question — where this deployment is
5716+
* REACHED, which behind a proxy or a tunnel has no relation to what this
5717+
* process bound — so they keep winning, https listener or not, and an
5718+
* `http://` value under a TLS listener is an operator's deliberate statement
5719+
* rather than a default to override.
5720+
*
5721+
* The parameter DEFAULTS to `'http'`, which is what every caller that never
5722+
* passes it resolved to before this existed: on a tree with no TLS flags in
5723+
* play the value, the source and the origin are byte-for-byte what they were.
56335724
*/
5634-
export function resolveAuthBaseUrl(port: number | string): AuthBaseUrlResolution {
5725+
export function resolveAuthBaseUrl(
5726+
port: number | string,
5727+
boundProtocol: ListenerProtocol = 'http',
5728+
): AuthBaseUrlResolution {
56355729
const value = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', { silent: true })
56365730
?? process.env.OS_BASE_URL
5637-
?? `http://localhost:${port}`;
5731+
?? `${boundProtocol}://localhost:${port}`;
56385732

56395733
// Mirrors readEnvWithDeprecation's own precedence (preferred, then legacy),
56405734
// for REPORTING only — the value above is what actually takes effect.

0 commit comments

Comments
 (0)