From 2b17decab348c6c997ec0a24806fbfc9ae8aac7f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 21:42:33 +0000 Subject: [PATCH 1/5] feat(cli): terminate TLS in `os dev` from a developer-supplied cert, and derive the canonical origin from the listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `objectstack dev --cert --key ` 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 --- packages/cli/src/commands/dev.ts | 49 +++- packages/cli/src/commands/serve.ts | 110 ++++++++- packages/cli/src/utils/dev-tls-contract.ts | 233 ++++++++++++++++++ .../plugins/plugin-hono-server/src/adapter.ts | 73 +++++- .../plugin-hono-server/src/hono-plugin.ts | 18 +- 5 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/utils/dev-tls-contract.ts diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 8bb27f19db..0d9b5f2921 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -24,6 +24,18 @@ import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types'; // no range, no reader, no wording; a second copy of the bound is exactly what // #12620 and #12662 protected against. import { describePortSource, parseRequestedPort, formatInvalidPortNotice } from '../utils/port-contract.js'; +// The ONE dev-TLS contract, shared with the `serve` child this command spawns +// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no +// certificate is ever generated — the developer brings their own. +import { + devTlsCertFlag, + devTlsKeyFlag, + resolveDevTlsIntent, + listenerProtocol, + devTlsChildArgs, + colorizeDevTlsNotice, + type ListenerProtocol, +} from '../utils/dev-tls-contract.js'; // The auth base-URL precedence chain, borrowed from the command that owns it // (#16734). ⛔ `dev` declares no chain of its own — see printMcpConnectHint. import { resolveAuthBaseUrl } from './serve.js'; @@ -106,8 +118,10 @@ export async function resolveDevDatabase(opts: { * here is no block at all: falling back to the bound socket would reprint, on * the same screen, the exact address the banner just refused to print. */ -export function printMcpConnectHint(opts: { boundPort: number | string; name: string }): void { - const { baseOrigin } = resolveAuthBaseUrl(opts.boundPort); +export function printMcpConnectHint( + opts: { boundPort: number | string; name: string; boundProtocol?: ListenerProtocol }, +): void { + const { baseOrigin } = resolveAuthBaseUrl(opts.boundPort, opts.boundProtocol ?? 'http'); if (baseOrigin === null) return; console.log(); console.log(chalk.cyan(' 🤖 MCP server — connect a coding agent:')); @@ -134,6 +148,11 @@ export default class Dev extends Command { options: ['debug', 'info', 'warn', 'error', 'fatal', 'silent'], }), port: Flags.string({ char: 'p', description: 'Server port (overrides $PORT)' }), + // #16804 — developer-supplied TLS, forwarded to the `serve` child. Declared + // through the shared contract so the two commands cannot drift on the flag + // names, the prose, or what half a pair means. + cert: devTlsCertFlag(), + key: devTlsKeyFlag(), preset: Flags.string({ description: 'Plugin tier preset forwarded to `serve`: minimal | default | full', }), @@ -211,6 +230,19 @@ export default class Dev extends Command { const { args, flags } = await this.parse(Dev); const packageName = args.package; + // ── The TLS pair, read ONCE, ahead of every child and every printer ── + // Refused here rather than one process later so half a pair is named under + // the spelling the operator typed, and resolved before the connect hint + // below so the parent's derived origin and the child's bound socket come + // from the same answer (#16804). ⛔ This reads no certificate — the child + // binds the socket, so the child owns the refusal for an unusable file. + const tlsIntent = resolveDevTlsIntent(flags); + if (tlsIntent.kind === 'incomplete') { + console.error(colorizeDevTlsNotice(tlsIntent.notice)); + process.exit(1); + } + const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent); + // Load .env files following Vite/Next.js convention (mirrors `serve`). // `dev` is always development mode, so prefer `.env.development*` over // `.env.production*`. Loaded BEFORE any env lookups. @@ -514,6 +546,10 @@ export default class Dev extends Command { 'serve', '--dev', ...(port ? ['--port', port] : []), + // The PATHS, not the bytes: the child binds the socket, so it is + // the process that must fail when the certificate is unusable + // (#16804). One reader of the file, one owner of that refusal. + ...devTlsChildArgs(tlsIntent), ...(flags.ui ? ['--ui'] : []), ...(flags.verbose ? ['--verbose'] : []), ...(flags['log-level'] ? ['--log-level', flags['log-level']] : []), @@ -555,7 +591,14 @@ export default class Dev extends Command { // origin these lines carry is resolved from the runtime's own // precedence chain inside the printer, so the banner the child // prints and this block cannot name two different deployments. - printMcpConnectHint({ boundPort: actual, name: path.basename(process.cwd()) || 'objectstack' }); + printMcpConnectHint({ + boundPort: actual, + name: path.basename(process.cwd()) || 'objectstack', + // ⭐ From THIS process's own flags, which are the same flags it + // forwarded to the child — so the scheme the hint prints and the + // scheme the child bound cannot part company (#16804). + boundProtocol, + }); } } }); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e73424b4ac..8afd9f401e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -62,6 +62,20 @@ import { MAX_PORT, type PortInputSource, } from '../utils/port-contract.js'; +// The ONE dev-TLS contract, shared with `dev` (which spawns this command) so the +// protocol the parent derives and the protocol this process actually binds come +// from a single reader (#16804). ⛔ Nothing about certificates is declared in +// this file, and ⛔ nothing anywhere generates one — see that module's header. +import { + devTlsCertFlag, + devTlsKeyFlag, + resolveDevTlsIntent, + readDevTlsMaterial, + listenerProtocol, + colorizeDevTlsNotice, + type DevTlsMaterial, + type ListenerProtocol, +} from '../utils/dev-tls-contract.js'; import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js'; import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js'; // [ADR-0130 D4 / option B, #15006] Every read below that keys off a @@ -542,8 +556,21 @@ export interface BoundPortChannels { * it can only lose the race often enough for someone to notice — which is * precisely the year-of-flakes this replaces. */ -export function publishBoundPort(boundPort: number, channels: BoundPortChannels): void { - const url = `http://localhost:${boundPort}`; +export function publishBoundPort( + boundPort: number, + channels: BoundPortChannels, + boundProtocol: ListenerProtocol = 'http', +): void { + // ⭐ The SOCKET's own address, so the scheme has to be the one the socket + // actually speaks (#16804). Both consumers of this `url` open it: the runtime + // state file is what an external supervisor or health check dials, and the + // IPC message is what the `os dev` parent learns the server from. Under + // `--cert`/`--key` a hardcoded `http://` here would hand both of them an + // address that answers a TLS handshake error — a machine-readable surface + // saying something untrue about the process that wrote it. ⛔ Unlike the + // canonical origin, this is NOT overridable by `OS_AUTH_URL`: that variable + // names where a deployment is REACHED, and this line names what was BOUND. + const url = `${boundProtocol}://localhost:${boundPort}`; // 1 ─ THE FILE FIRST. Both announcements below send a consumer to it. channels.writeRuntimeState({ port: boundPort, url }); // 2 ─ IPC: the `os dev` parent learns the real port without polling. @@ -947,6 +974,11 @@ export default class Serve extends Command { options: [...LOG_LEVELS], }), verbose: Flags.boolean({ char: 'v', description: 'Verbose output — shortcut for --log-level debug.' }), + // #16804 — developer-supplied TLS, declared through the shared contract so + // `dev` (which spawns this command) and `serve` cannot drift on the flag + // names, the prose, or what half a pair means. + cert: devTlsCertFlag(), + key: devTlsKeyFlag(), }; /** @@ -1879,6 +1911,38 @@ export default class Serve extends Command { } const requestedPort = parsedPort; + // ── The TLS pair, refused here for the same reason the port is (#16804) ── + // Ahead of every socket and every import that branches on the protocol, and + // BEFORE `resolveAuthBaseUrl` is consulted anywhere: the canonical origin's + // fallback tail is derived from this answer, so a pair that will not be + // honoured must never reach the resolver as if it would be. + const tlsIntent = resolveDevTlsIntent(flags); + if (tlsIntent.kind === 'incomplete') { + printDiagnostic(colorizeDevTlsNotice(tlsIntent.notice)); + this.exit(1); + } + /** + * The scheme this process will actually speak — `https` only when a + * certificate and a key were BOTH given. Every origin this boot advertises + * is built from it, and there is no other way to set it. + */ + const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent); + /** + * PEM bytes for the listener, read HERE so an unreadable certificate is + * refused by the process that would have bound the socket, naming the flag + * and the path — ⛔ never degraded to a plain-http listener (see + * `formatUnreadableDevTlsFileNotice`). + */ + let tlsMaterial: DevTlsMaterial | undefined; + if (tlsIntent.kind === 'requested') { + try { + tlsMaterial = readDevTlsMaterial(tlsIntent); + } catch (e: any) { + printDiagnostic(colorizeDevTlsNotice(String(e?.message ?? e))); + this.exit(1); + } + } + // ── …and it has to BE the port the text SAYS, or say otherwise (#12674) ── // `parseInt` is kept as the reader (#12662's ruling: nothing that boots // today may be refused), and its tolerance changes the answer rather than @@ -3242,7 +3306,13 @@ export default class Serve extends Command { if (flags.server && !configHasHonoServer) { try { const { HonoServerPlugin } = await import('@objectstack/plugin-hono-server'); - const serverPlugin = new HonoServerPlugin({ port }); + // `tls` is `undefined` unless --cert/--key were both given and both + // files read (#16804) — the adapter then binds a TLS listener with + // the same fetch handler. ⛔ Nothing here generates a certificate. + const serverPlugin = new HonoServerPlugin({ + port, + tls: tlsMaterial ? { cert: tlsMaterial.cert, key: tlsMaterial.key } : undefined, + }); await kernel.use(serverPlugin); trackPlugin('HonoServer'); } catch (e: any) { @@ -3567,7 +3637,7 @@ export default class Serve extends Command { // additionally reports where the value came from and whether it // parses, so an unusable one can be said out loud instead of // vanishing into an empty catch (#10202). - const baseUrlResolution = resolveAuthBaseUrl(port); + const baseUrlResolution = resolveAuthBaseUrl(port, boundProtocol); const baseUrl = baseUrlResolution.value; const socialProviders: Record = {}; @@ -4851,7 +4921,7 @@ export default class Serve extends Command { // the bound one. `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(boundPort).baseOrigin, + externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin, ...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }), isDev, pluginCount: loadedPlugins.length, @@ -4914,7 +4984,7 @@ export default class Serve extends Command { // the file is written BEFORE either channel announces the address that // sends a consumer to it. {@link publishBoundPort} carries the race the // old order lost, and the reason the repair is not reader-side polling. - publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner)); + publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol); // ── Watch the served database file's identity ────────────────── // Deleting the data directory under a running server (`rm -rf @@ -5630,11 +5700,35 @@ export interface AuthBaseUrlResolution { * `baseOrigin` is spelled `${protocol}//${host}` rather than `URL.origin` * because that is what the inline code computed, and the two disagree for * non-special schemes (`URL.origin` answers the string `"null"`). + * + * ## The tail follows the LISTENER, and only the tail (#16804) + * + * `boundProtocol` names the scheme this process actually bound — `https` + * exactly when `--cert`/`--key` were both given, derived in `dev-tls-contract` + * from that material and settable nowhere else. It reaches only the built-in + * default at the end of the chain, because that default is the one link nobody + * configured: it is this process describing its own socket, and once TLS + * terminates in-process `http://localhost:` is an address no client can + * reach. + * + * ⛔ Every CONFIGURED link is untouched. `OS_AUTH_URL`, its legacy name and + * `OS_BASE_URL` answer a different question — where this deployment is + * REACHED, which behind a proxy or a tunnel has no relation to what this + * process bound — so they keep winning, https listener or not, and an + * `http://` value under a TLS listener is an operator's deliberate statement + * rather than a default to override. + * + * The parameter DEFAULTS to `'http'`, which is what every caller that never + * passes it resolved to before this existed: on a tree with no TLS flags in + * play the value, the source and the origin are byte-for-byte what they were. */ -export function resolveAuthBaseUrl(port: number | string): AuthBaseUrlResolution { +export function resolveAuthBaseUrl( + port: number | string, + boundProtocol: ListenerProtocol = 'http', +): AuthBaseUrlResolution { const value = readEnvWithDeprecation('OS_AUTH_URL', 'BETTER_AUTH_URL', { silent: true }) ?? process.env.OS_BASE_URL - ?? `http://localhost:${port}`; + ?? `${boundProtocol}://localhost:${port}`; // Mirrors readEnvWithDeprecation's own precedence (preferred, then legacy), // for REPORTING only — the value above is what actually takes effect. diff --git a/packages/cli/src/utils/dev-tls-contract.ts b/packages/cli/src/utils/dev-tls-contract.ts new file mode 100644 index 0000000000..aaa6f2b0af --- /dev/null +++ b/packages/cli/src/utils/dev-tls-contract.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE dev-TLS contract this CLI has: the `--cert` / `--key` pair, the + * protocol a listener carrying them speaks, and the prose every door refuses in + * (#16804). + * + * ## What this is for, and the ONE thing it deliberately is NOT + * + * An interactive MCP client refuses to open an OAuth sign-in against a non-TLS + * URL, so the self-serve identity path the product advertises could not be + * exercised against a local dev server at all — every demo, recording and + * investigation hand-built a page of reverse-proxy setup off-camera. These two + * flags let the developer hand `objectstack dev` a certificate they already + * have and let the dev process terminate TLS itself. + * + * ⛔ **Nothing here generates a certificate or a CA, and nothing here tells a + * developer how to install one into a system trust store.** The maintainer's + * ruling (2026-09-10, option N) refused the generating shape on a + * security-statement ground: a product that generates a CA and then instructs + * developers to trust it owns that instruction, and an error in it is invisible + * to the person following it. The trust store is the developer's own business. + * The developer brings the certificate; this module's whole job is to USE it. + * + * ## Why the pair is refused HERE rather than at the flag layer + * + * `--cert` without `--key` (or the reverse) is not a TLS configuration — it is + * half of one, and the honest answer is a refusal that names the missing flag. + * oclif's `dependsOn` would express that, but the refusal it prints names the + * flag pair without saying what the operator was trying to do, and + * {@link formatIncompleteDevTlsPairNotice} is read by a pin test rather than + * only by a human. Same judgement, and the same reason, as + * `port-contract.ts`'s explicit pre-spawn call: the doors call this module. + * + * ## Why the protocol is DERIVED and never configured + * + * `resolveAuthBaseUrl`'s fallback tail used to be a hardcoded + * `http://localhost:`, which is the address of the socket — and once TLS + * terminates in-process that address is unreachable. A listener that speaks TLS + * and a canonical origin that says `http://` is strictly worse than today's + * plain-http server, because the boot output is then self-consistently WRONG + * rather than merely plain: the two `/.well-known/*` documents, the CSRF + * allow-list, the ready banner's `API:` row and the `🤖 MCP server` block would + * all agree on an origin no client can reach. So the protocol is a function of + * the material the listener was handed ({@link listenerProtocol}) and of + * nothing else — there is no flag, and no env var, that can set it + * independently of the certificate that makes it true. + * + * `OS_AUTH_URL` (and the rest of that chain) keeps winning over the derived + * value: it names where a deployment is REACHED, which is a different question + * from what this process bound. Only the tail of the chain — the built-in + * default nobody configured — follows the listener. + */ +import { readFileSync } from 'node:fs'; +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +/** + * The scheme a bound listener actually speaks. Two values, because that is how + * many a Node HTTP listener has; the point of naming the type is that callers + * pass a *measured* protocol rather than a boolean whose polarity is guessable + * at the call site. + */ +export type ListenerProtocol = 'http' | 'https'; + +/** The flag names, declared once so prose, forwarding and tests cannot drift. */ +export const DEV_TLS_CERT_FLAG = 'cert'; +export const DEV_TLS_KEY_FLAG = 'key'; + +/** Both flags as an operator typed them — the shape every door parses from. */ +export interface DevTlsFlagInput { + [DEV_TLS_CERT_FLAG]?: string; + [DEV_TLS_KEY_FLAG]?: string; +} + +/** A requested TLS listener: the two paths, not yet read. */ +export interface DevTlsRequest { + certPath: string; + keyPath: string; +} + +/** PEM bytes for a requested listener, plus the paths they were read from. */ +export interface DevTlsMaterial extends DevTlsRequest { + cert: Buffer; + key: Buffer; +} + +/** + * What the flag pair says, as one of three answers — ⛔ never a boolean plus a + * separate validity bit, which is the shape that lets a door act on "TLS was + * asked for" while ignoring "…and it was asked for wrong". + * + * `none` is the overwhelmingly common case and the one that must stay + * byte-for-byte identical to a tree with no TLS support at all. + */ +export type DevTlsIntent = + | { kind: 'none' } + | { kind: 'incomplete'; notice: string } + | ({ kind: 'requested' } & DevTlsRequest); + +/** + * Read the `--cert` / `--key` pair. PURE — it touches no filesystem, so the + * `os dev` parent can learn the protocol its child will speak without reading + * the certificate it merely forwards. + * + * Whitespace-only is treated as absent: `--cert ''` is a value an operator can + * produce from a shell variable that did not expand, and reading it as a path + * would refuse with `ENOENT ''` instead of naming the flag. + */ +export function resolveDevTlsIntent(flags: DevTlsFlagInput): DevTlsIntent { + const certPath = flags[DEV_TLS_CERT_FLAG]?.trim() ?? ''; + const keyPath = flags[DEV_TLS_KEY_FLAG]?.trim() ?? ''; + + if (!certPath && !keyPath) return { kind: 'none' }; + if (!certPath) return { kind: 'incomplete', notice: formatIncompleteDevTlsPairNotice(DEV_TLS_KEY_FLAG) }; + if (!keyPath) return { kind: 'incomplete', notice: formatIncompleteDevTlsPairNotice(DEV_TLS_CERT_FLAG) }; + return { kind: 'requested', certPath, keyPath }; +} + +/** + * The protocol a listener handed `request` speaks. + * + * Takes the resolved intent rather than the raw flags so an `incomplete` pair + * can never resolve to `https`: a door that refuses the pair and a door that + * derives the origin read the same answer. + */ +export function listenerProtocol(intent: DevTlsIntent): ListenerProtocol { + return intent.kind === 'requested' ? 'https' : 'http'; +} + +/** + * The refusal for half a TLS pair, naming the flag that WAS given and the one + * that is missing. + * + * Returns text rather than printing it, so the decision to warn or to exit + * stays at the call site and a test can read the sentence without capturing a + * stream — the same division as `formatInvalidPortNotice`. + */ +export function formatIncompleteDevTlsPairNotice(given: typeof DEV_TLS_CERT_FLAG | typeof DEV_TLS_KEY_FLAG): string { + const missing = given === DEV_TLS_CERT_FLAG ? DEV_TLS_KEY_FLAG : DEV_TLS_CERT_FLAG; + return `--${given} was given without --${missing}.\n` + + ` TLS needs both halves: --${DEV_TLS_CERT_FLAG} ` + + `--${DEV_TLS_KEY_FLAG} .\n` + + ' Drop both to serve plain http on this port.'; +} + +/** + * The refusal for a certificate or key that cannot be read, naming the flag, + * the path it resolved to and what the filesystem said. + * + * ⛔ There is deliberately no path from here back to plain http. A developer who + * typed `--cert` asked for TLS; starting an http listener instead would answer + * a request for one protocol with a server speaking the other, and the first + * symptom would be a client-side handshake error naming neither the flag nor + * the file. Prefer failing to falling back. + */ +export function formatUnreadableDevTlsFileNotice( + flag: typeof DEV_TLS_CERT_FLAG | typeof DEV_TLS_KEY_FLAG, + path: string, + cause: unknown, +): string { + const reason = cause instanceof Error ? cause.message : String(cause); + return `--${flag} could not be read: ${JSON.stringify(path)}\n` + + ` ${reason}\n` + + ' The path is resolved relative to the current working directory.'; +} + +/** + * Read the PEM bytes for a requested listener. + * + * Throws with {@link formatUnreadableDevTlsFileNotice} as the message, because + * the only correct answer to an unreadable certificate is to not start — see + * that function for why there is no http fallback here. + */ +export function readDevTlsMaterial(request: DevTlsRequest): DevTlsMaterial { + const read = (flag: typeof DEV_TLS_CERT_FLAG | typeof DEV_TLS_KEY_FLAG, path: string): Buffer => { + try { + return readFileSync(path); + } catch (e) { + throw new Error(formatUnreadableDevTlsFileNotice(flag, path, e)); + } + }; + return { + ...request, + cert: read(DEV_TLS_CERT_FLAG, request.certPath), + key: read(DEV_TLS_KEY_FLAG, request.keyPath), + }; +} + +/** + * The argv `os dev` forwards to the `serve` child for a resolved intent. + * + * The child re-reads the files itself rather than receiving bytes over IPC: it + * is the process that binds the socket, so it must be the process that fails + * when the certificate is unusable. Forwarding the PATHS keeps one reader of + * the file and one owner of that refusal. + * + * An `incomplete` pair forwards nothing — the parent has already refused it, and + * forwarding half a pair would have the child refuse it a second time under the + * name of a channel the operator did not use. + */ +export function devTlsChildArgs(intent: DevTlsIntent): string[] { + if (intent.kind !== 'requested') return []; + return [`--${DEV_TLS_CERT_FLAG}`, intent.certPath, `--${DEV_TLS_KEY_FLAG}`, intent.keyPath]; +} + +/** + * Declare `--cert` on a command. + * + * The description says what the flag does and what the developer still owns — + * ⛔ and it does not tell anyone how to make a certificate trusted. See this + * module's header: that sentence is refused by ruling, and `--help` is one of + * the surfaces it is refused on. + */ +export function devTlsCertFlag() { + return Flags.string({ + description: 'Path to a TLS certificate (PEM). With --key, terminate TLS in this process and serve ' + + 'https://localhost: — the canonical origin, both /.well-known/* documents and the MCP ' + + 'connect hint follow. Bring your own certificate; none is generated.', + }); +} + +/** Declare `--key` on a command. Same division of labour as {@link devTlsCertFlag}. */ +export function devTlsKeyFlag() { + return Flags.string({ + description: 'Path to the private key (PEM) for --cert. Required with --cert, and ignored without it.', + }); +} + +/** The incomplete-pair refusal, coloured for a terminal. */ +export function colorizeDevTlsNotice(notice: string): string { + return chalk.red(` ✗ ${notice}`); +} diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 72e3321c91..c72e7fe28e 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -26,6 +26,12 @@ import { Hono } from 'hono'; import { routePath } from 'hono/route'; import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; +// The TLS listener factory (#16804). `@hono/node-server` takes it as an +// OPTION — its `Options` type is a union whose https arm is +// `{ createServer: typeof https.createServer; serverOptions: https.ServerOptions }` +// — so terminating TLS needs no bridging code here: the same `fetch` handler, +// the same drain, one different server factory. See {@link HttpsListenerMaterial}. +import { createServer as createHttpsServer } from 'node:https'; import { matchesRoutePattern } from './route-pattern'; // The ADR-0112 wire vocabulary, read as DATA rather than restated: `ErrorCode` // is the closed union (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) a registered @@ -82,6 +88,28 @@ export const DEFAULT_CORS_EXPOSE_HEADERS: readonly string[] = Object.freeze([ 'x-objectstack-dropped-fields', ]); +/** + * PEM bytes for a TLS listener — the certificate and its private key, already + * read (#16804). + * + * ⭐ BYTES, ⛔ not paths, and that division is the contract. Whoever hands this + * adapter TLS material is the layer that knows WHY it has it — in the shipped + * case, `os dev --cert … --key …`, which owns the flag names and can therefore + * refuse an unreadable file by naming the flag the operator typed. A transport + * adapter that took paths would have to invent that refusal from a filename, + * and would own a second reader of the same file. + * + * ⛔ Nothing in this package generates a certificate, and nothing in it says + * anything about installing one into a trust store. Absent this option the + * listener is plain http, exactly as it always was. + */ +export interface HttpsListenerMaterial { + /** The certificate chain, PEM. */ + cert: string | Buffer; + /** The certificate's private key, PEM. */ + key: string | Buffer; +} + export interface HonoCorsOptions { enabled?: boolean; origins?: string | string[]; @@ -414,11 +442,25 @@ export class HonoHttpServer implements IHttpServer { * `shutdownTimeout` so a slow request can't hang the whole shutdown. */ private drainTimeoutMs: number = 10_000, + /** + * When present, {@link tryListen} binds a TLS listener instead of a + * plain one (#16804). Fixed at construction because it decides what + * the socket IS, not how a request is handled: every origin the boot + * advertises is derived from the same answer one layer up, so a + * mid-flight change would leave the process speaking one protocol and + * advertising another. + */ + private tls?: HttpsListenerMaterial, ) { this.app = new Hono(); this.installErrorEnvelopeSeam(); } + /** The scheme this server binds — derived from {@link tls}, settable nowhere else. */ + getProtocol(): 'http' | 'https' { + return this.tls ? 'https' : 'http'; + } + // internal helper to convert standard handler to Hono handler private wrap(handler: RouteHandler) { return async (c: any) => { @@ -1545,13 +1587,30 @@ export class HonoHttpServer implements IHttpServer { private tryListen(port: number): Promise { return new Promise((resolve, reject) => { - const server = serve({ - fetch: this.app.fetch, - port - }, (info) => { - this.listeningPort = info.port; - resolve(); - }); + // ⛔ The two arms are spelled out rather than assembled from a + // spread, because `@hono/node-server`'s `Options` is a UNION of + // per-protocol arms: a conditionally-built object widens to the + // union and loses the pairing between `createServer` and the + // `serverOptions` that factory accepts — which is the one thing a + // type can check here. Everything else about the two calls, and + // everything about `close()`, is identical. + const server = this.tls + ? serve({ + fetch: this.app.fetch, + port, + createServer: createHttpsServer, + serverOptions: { cert: this.tls.cert, key: this.tls.key }, + }, (info) => { + this.listeningPort = info.port; + resolve(); + }) + : serve({ + fetch: this.app.fetch, + port + }, (info) => { + this.listeningPort = info.port; + resolve(); + }); this.server = server; server.on('error', (err: any) => { reject(err); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 4cb977a388..7e7df89c0a 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -14,6 +14,7 @@ import { serveStatic } from '@hono/node-server/serve-static'; import * as fs from 'fs'; import * as path from 'path'; import { createOriginMatcher, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher'; +import type { HttpsListenerMaterial } from './adapter'; import { readEnvWithDeprecation } from '@objectstack/types'; import { PerfTiming, @@ -35,6 +36,16 @@ export interface StaticMount { export interface HonoPluginOptions { port?: number; + /** + * Terminate TLS on this server's own socket, using the caller's PEM bytes + * (#16804). + * + * Absent — the overwhelmingly common case, and the only one before this + * option existed — the listener is plain http and nothing about the boot + * changes. ⛔ This package neither generates a certificate nor says anything + * about trusting one; see {@link HttpsListenerMaterial}. + */ + tls?: HttpsListenerMaterial; staticRoot?: string; /** * Multiple static resource mounts @@ -245,8 +256,11 @@ export class HonoServerPlugin implements Plugin { spaFallback: false, ...options }; - // We handle static root manually in start() to support SPA fallback - this.server = new HonoHttpServer(this.options.port); + // We handle static root manually in start() to support SPA fallback. + // `undefined` for staticRoot and the drain window keeps both at the + // adapter's own defaults — named positionally only because `tls` + // follows them (#16804). + this.server = new HonoHttpServer(this.options.port, undefined, undefined, this.options.tls); } /** From da5f91c726ed47939b08cea8195357aa1b5aee0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:02:16 +0000 Subject: [PATCH 2/5] test(cli,hono): pin the TLS listener, the derived origin, and the no-flag ablation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `adapter-tls-listener.test.ts` drives a real ephemeral TLS socket with a certificate the test mints into a tempdir, and drives the two legs against each other: the https listener refuses a plain-http request and the plain listener refuses a TLS one, so neither leg can pass by being broken. - `dev-tls-contract.test.ts` pins the three answers of the flag pair, the refusals, and — negatively — that no flag description and no line of the module mentions generating a certificate or trusting one, with an anti-vacuity case proving the same scan reads words that are there. - `dev-mcp-connect-hint-origin.test.ts` gains the https acceptance (banner row and all three hint lines, `OS_AUTH_URL` unset) and an ablation leg: the boot that passes no protocol at all and the boot that passes `http` must be byte-identical, and both must differ from the https leg. - `serve-auth-base-url-diagnostic.test.ts` pins that the listener protocol reaches the built-in tail and stops there — every configured value keeps winning, an `http://` one included. - `serve-bound-port-publication.test.ts` gains the same ablation for the state file and the IPC message, and its two source pins are updated to the spellings this change moved. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- .../dev-mcp-connect-hint-origin.test.ts | 132 ++++++++- .../serve-auth-base-url-diagnostic.test.ts | 65 +++++ .../serve-bound-port-publication.test.ts | 61 +++- .../cli/src/utils/dev-tls-contract.test.ts | 271 ++++++++++++++++++ .../src/adapter-tls-listener.test.ts | 196 +++++++++++++ 5 files changed, 720 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/utils/dev-tls-contract.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/adapter-tls-listener.test.ts diff --git a/packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts b/packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts index 0ba1f1e6dd..63f8092f1f 100644 --- a/packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts +++ b/packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts @@ -89,7 +89,28 @@ describe('os dev MCP connect hint — origin (#16734)', () => { * child's banner (its own call site's expression, verbatim) and then the * parent's connect hint, both told the port the server ACTUALLY bound. */ - const boot = (boundPort: number, name = 'hotcrm') => { + const boot = (boundPort: number, name = 'hotcrm', boundProtocol: 'http' | 'https' = 'http') => { + printServerReady({ + ...bannerOpts, + externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin, + }); + printMcpConnectHint({ boundPort, name, boundProtocol }); + return lines.join('\n'); + }; + + /** + * The same boot with the protocol argument WITHHELD from both printers — the + * expression this file carried before `--cert`/`--key` existed, character for + * character. + * + * ⭐ This is the ABLATION LEG for acceptance 2 (「无 flag 时逐字节等于今天」, + * #16804). The pins below drive it beside {@link boot}'s no-flag case and + * require the two to be byte-identical, so the claim "nothing changes without + * the flags" is measured rather than asserted: it would go red the day the + * derived protocol leaked into the default, and it cannot pass by both legs + * being broken in the same direction. + */ + const bootWithoutProtocolArg = (boundPort: number, name = 'hotcrm') => { printServerReady({ ...bannerOpts, externalBaseOrigin: resolveAuthBaseUrl(boundPort).baseOrigin }); printMcpConnectHint({ boundPort, name }); return lines.join('\n'); @@ -203,10 +224,117 @@ describe('os dev MCP connect hint — origin (#16734)', () => { }); }); + // ── #16804 ─ a TLS listener, and the three surfaces that follow it ────── + describe('under --cert/--key the derived origin is https, and everything follows', () => { + it('acceptance 1: with OS_AUTH_URL UNSET, banner and hint both give https', () => { + // Every chain variable is deleted by `beforeEach`, so the only thing + // that can produce `https` here is the listener protocol reaching the + // resolver's built-in tail. + const output = boot(3000, 'hotcrm', 'https'); + + expect(output).toContain('MCP: https://localhost:3000/api/v1/mcp'); + expect(output).toContain('Endpoint https://localhost:3000/api/v1/mcp'); + expect(output).toContain('Skill https://localhost:3000/api/v1/mcp/skill'); + expect(output).toContain( + 'Connect claude mcp add --transport http hotcrm https://localhost:3000/api/v1/mcp', + ); + // The plain-http address must not appear anywhere in a TLS boot's output: + // it is the one address a client on this port cannot reach. + expect(output).not.toContain('http://localhost:3000'); + expect(mcpOrigins(output)).toEqual(['https://localhost:3000']); + }); + + it("follows dev's auto-shifted port under TLS too", () => { + expect(mcpOrigins(boot(3001, 'hotcrm', 'https'))).toEqual(['https://localhost:3001']); + }); + + it('acceptance 3: `OS_AUTH_URL` still WINS over the derived https origin', () => { + // The override direction that matters in practice: a developer + // terminating TLS locally but reached through a tunnel on another host. + process.env.OS_AUTH_URL = 'https://tunnel.example.com'; + expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['https://tunnel.example.com']); + }); + + it('acceptance 3, the awkward direction: an http OS_AUTH_URL wins as well', () => { + // ⛔ Deliberately NOT "upgraded" to https. `OS_AUTH_URL` names where the + // deployment is REACHED — behind a TLS-terminating proxy that forwards + // plain http, or in a test harness, that is a deliberate statement about + // a different hop, and a default has no standing to overrule it. + process.env.OS_AUTH_URL = 'http://proxied.example.com'; + expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['http://proxied.example.com']); + }); + + it('the rest of the configured chain keeps its precedence under TLS', () => { + process.env.OS_BASE_URL = 'https://base.example.com'; + expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['https://base.example.com']); + }); + + it('an unusable value stays unusable — TLS does not manufacture an origin', () => { + process.env.OS_AUTH_URL = ''; + const output = boot(3000, 'hotcrm', 'https'); + expect(mcpOrigins(output)).toEqual([]); + expect(output).not.toContain('claude mcp add'); + expect(output).not.toContain('https://localhost:3000'); + }); + }); + + // ── #16804 ─ acceptance 2, as an ABLATION rather than a claim ────────── + describe('without the flags the output is byte-for-byte what it was', () => { + it('the no-flag boot equals the boot that never passes a protocol at all', () => { + // Leg A: the call shape this file used before `--cert`/`--key` existed. + const before = bootWithoutProtocolArg(3000, 'my-app'); + lines.length = 0; + // Leg B: the same boot through today's call shape, no flags given. + const after = boot(3000, 'my-app', 'http'); + + expect(after).toBe(before); + // And the byte the two legs are about: still plain http, on the bound port. + expect(mcpOrigins(after)).toEqual(['http://localhost:3000']); + }); + + it('the two legs also agree on an auto-shifted port and an ephemeral one', () => { + for (const port of [3001, 45064]) { + lines.length = 0; + const before = bootWithoutProtocolArg(port, 'my-app'); + lines.length = 0; + expect(boot(port, 'my-app', 'http')).toBe(before); + } + }); + + it('and the legs DISCRIMINATE — the https leg differs from both', () => { + // Without this, two legs that both silently produced nothing would pass. + const plain = bootWithoutProtocolArg(3000, 'my-app'); + lines.length = 0; + const tls = boot(3000, 'my-app', 'https'); + + expect(tls).not.toBe(plain); + expect(mcpOrigins(plain)).toEqual(['http://localhost:3000']); + expect(mcpOrigins(tls)).toEqual(['https://localhost:3000']); + }); + }); + // ── What only the source can say ──────────────────────────────────────── describe('the call site feeds the printer the bound port, and nothing else', () => { it('hands `printMcpConnectHint` the ACTUALLY BOUND port', () => { - expect(DEV_SOURCE).toContain('printMcpConnectHint({ boundPort: actual,'); + expect(DEV_SOURCE).toContain('printMcpConnectHint({'); + expect(DEV_SOURCE).toContain('boundPort: actual,'); + }); + + it('hands it the protocol it FORWARDED, not one derived a second time (#16804)', () => { + // The parent's `boundProtocol` comes from the same `tlsIntent` that built + // the child's argv, so the scheme the hint prints and the scheme the + // child bound cannot part company. A second `resolveDevTlsIntent` call + // here would be a second reader, free to disagree. + expect(DEV_SOURCE).toContain('boundProtocol,'); + expect(DEV_SOURCE).toContain('const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent);'); + expect(DEV_SOURCE.match(/resolveDevTlsIntent\(/g) ?? []).toHaveLength(1); + }); + + it('forwards the cert/key PATHS to the serve child through the shared contract', () => { + expect(DEV_SOURCE).toContain('...devTlsChildArgs(tlsIntent),'); + // ⛔ and never the bytes: one reader of the file, one owner of the refusal. + expect(DEV_SOURCE).not.toContain('readDevTlsMaterial'); + expect(DEV_SOURCE).not.toContain('readFileSync(flags.cert'); }); it('builds no address out of the listening message any more', () => { diff --git a/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts b/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts index f958f2aa2c..d5d4b72691 100644 --- a/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts +++ b/packages/cli/src/commands/serve-auth-base-url-diagnostic.test.ts @@ -135,6 +135,71 @@ describe('resolveAuthBaseUrl — precedence (pre-existing behaviour, unchanged)' }); }); +/** + * #16804 — the built-in default follows the LISTENER, and nothing else does. + * + * `os dev --cert … --key …` terminates TLS in the dev process, after which + * `http://localhost:` is an address no client can reach. The tail is the + * one link in the chain nobody configured — it is this process describing its + * own socket — so it is the only link that moves. + */ +describe('resolveAuthBaseUrl — the listener protocol reaches the TAIL and stops there', () => { + it('derives https://localhost: for a TLS listener with nothing else set', () => { + expect(resolveAuthBaseUrl(3000, 'https')).toEqual({ + value: 'https://localhost:3000', + source: null, + baseOrigin: 'https://localhost:3000', + }); + }); + + it('ABLATION: the omitted argument and an explicit `http` are the same call', () => { + // Acceptance 2 of the card — 「无 flag时逐字节等于今天」 — measured on the + // resolver rather than claimed. Driven for a plain port, dev's shifted + // port and an ephemeral one, because the tail interpolates the port. + for (const port of [3000, 3001, 45064]) { + expect(resolveAuthBaseUrl(port)).toEqual(resolveAuthBaseUrl(port, 'http')); + // …and the two legs DISCRIMINATE, so the equality above is a reading. + expect(resolveAuthBaseUrl(port)).not.toEqual(resolveAuthBaseUrl(port, 'https')); + } + }); + + it('⛔ a CONFIGURED value is never rewritten — OS_AUTH_URL wins under TLS', () => { + process.env.OS_AUTH_URL = 'https://tunnel.example.com'; + const r = resolveAuthBaseUrl(3000, 'https'); + expect(r.value).toBe('https://tunnel.example.com'); + expect(r.source).toBe('OS_AUTH_URL'); + }); + + it('⛔ including an http one — the scheme of a configured value is not upgraded', () => { + // The direction a "helpful" implementation gets wrong. `OS_AUTH_URL` names + // where the deployment is REACHED; behind a proxy that forwards plain http + // to this TLS listener, `http://…` is the operator's deliberate statement + // about a different hop, and a default has no standing to overrule it. + process.env.OS_AUTH_URL = 'http://proxied.example.com'; + expect(resolveAuthBaseUrl(3000, 'https').value).toBe('http://proxied.example.com'); + expect(resolveAuthBaseUrl(3000, 'https').baseOrigin).toBe('http://proxied.example.com'); + }); + + it('⛔ nor the legacy name, nor OS_BASE_URL', () => { + process.env.BETTER_AUTH_URL = 'http://legacy.example.com'; + expect(resolveAuthBaseUrl(3000, 'https').value).toBe('http://legacy.example.com'); + + delete process.env.BETTER_AUTH_URL; + process.env.OS_BASE_URL = 'http://base.example.com'; + expect(resolveAuthBaseUrl(3000, 'https').value).toBe('http://base.example.com'); + }); + + it('⛔ a set-but-empty value stays unusable — TLS manufactures no origin', () => { + // The tail is not reached at all here (`??` skips only unset values), so a + // TLS listener must not resurrect it. `baseOrigin: null` means the printers + // say nothing rather than guessing, and that is unchanged. + process.env.OS_AUTH_URL = ''; + const r = resolveAuthBaseUrl(3000, 'https'); + expect(r.value).toBe(''); + expect(r.baseOrigin).toBeNull(); + }); +}); + describe('resolveAuthBaseUrl — a usable base URL stays silent', () => { it.each([ ['OS_AUTH_URL', 'https://app.example.com'], diff --git a/packages/cli/src/commands/serve-bound-port-publication.test.ts b/packages/cli/src/commands/serve-bound-port-publication.test.ts index f6957e6411..8d7e841757 100644 --- a/packages/cli/src/commands/serve-bound-port-publication.test.ts +++ b/packages/cli/src/commands/serve-bound-port-publication.test.ts @@ -333,7 +333,7 @@ describe('#13062 all THREE channels publish that one number', () => { expect( SERVE, 'the banner no longer resolves its origin from `boundPort`', - ).toContain('externalBaseOrigin: resolveAuthBaseUrl(boundPort).baseOrigin'); + ).toContain('externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin'); }); it('the ONE wiring site hands the seam the BOUND port, never the requested one', () => { @@ -343,7 +343,7 @@ describe('#13062 all THREE channels publish that one number', () => { expect( SERVE, 'the publish site no longer hands `publishBoundPort` the resolved bound port', - ).toContain('publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner));'); + ).toContain('publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol);'); // Exactly two mentions in CODE: the declaration and that single call. expect( SERVE.match(/publishBoundPort\(/g) ?? [], @@ -351,6 +351,55 @@ describe('#13062 all THREE channels publish that one number', () => { ).toHaveLength(2); }); + describe('#16804 — the published url names the scheme the socket SPEAKS', () => { + it('plain http when nothing asked for TLS — today\'s bytes, unchanged', () => { + const seen: Array<{ port: number; url: string }> = []; + publishBoundPort(45070, { + writeRuntimeState: (published) => { seen.push(published); }, + announceListening: (message) => { seen.push({ port: message.port, url: message.url }); }, + printBanner: () => { /* not under test here */ }, + }); + expect(seen).toEqual([ + { port: 45070, url: 'http://localhost:45070' }, + { port: 45070, url: 'http://localhost:45070' }, + ]); + }); + + it('https on BOTH channels under a TLS listener — an ABLATION beside the leg above', () => { + // Both consumers of this url OPEN it: the runtime state file is what an + // external supervisor dials, the IPC message is what the `os dev` parent + // learns the server from. Under `--cert`/`--key` a hardcoded `http://` + // hands both an address that answers a TLS handshake error. + const seen: Array<{ port: number; url: string }> = []; + publishBoundPort(45071, { + writeRuntimeState: (published) => { seen.push(published); }, + announceListening: (message) => { seen.push({ port: message.port, url: message.url }); }, + printBanner: () => { /* not under test here */ }, + }, 'https'); + expect(seen).toEqual([ + { port: 45071, url: 'https://localhost:45071' }, + { port: 45071, url: 'https://localhost:45071' }, + ]); + }); + + it('the default parameter IS the old behaviour — omitted and `http` agree', () => { + const capture = (protocol?: 'http' | 'https') => { + const out: string[] = []; + const channels = { + writeRuntimeState: (published: { port: number; url: string }) => { out.push(published.url); }, + announceListening: (message: { url: string }) => { out.push(message.url); }, + printBanner: () => { /* not under test here */ }, + }; + if (protocol === undefined) publishBoundPort(45072, channels); + else publishBoundPort(45072, channels, protocol); + return out; + }; + expect(capture()).toEqual(capture('http')); + // …and the legs discriminate, so agreeing is a reading and not a vacuum. + expect(capture()).not.toEqual(capture('https')); + }); + }); + it('⛔ and NONE of the three has drifted back onto the requested port', () => { // The card's own instruction: three outputs of one defect, and repairing // one leaves two lying in a place nobody thinks to look next time. @@ -389,7 +438,13 @@ describe('#13062 all THREE channels publish that one number', () => { // The positive control for the negatives above: `port` has not been // globally renamed, so `not.toContain('port: Number(port)')` is a // measurement rather than a consequence of the variable disappearing. - expect(SERVE).toContain('new HonoServerPlugin({ port })'); + // + // #16804 widened the construction literal to carry `tls` beside `port`; + // what this control needs is that `port` still reaches the transport + // under its own name, so it reads the construction site rather than one + // formatting of it. + expect(SERVE).toContain('new HonoServerPlugin({'); + expect(SERVE.slice(SERVE.indexOf('new HonoServerPlugin({'))).toMatch(/^new HonoServerPlugin\(\{\s*\n\s*port,/); expect(SERVE).toContain('port = await getAvailablePort(requestedPort)'); }); }); diff --git a/packages/cli/src/utils/dev-tls-contract.test.ts b/packages/cli/src/utils/dev-tls-contract.test.ts new file mode 100644 index 0000000000..e1bbe10fe5 --- /dev/null +++ b/packages/cli/src/utils/dev-tls-contract.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The dev-TLS flag contract (#16804): what `--cert` / `--key` say, what half a + * pair says, and the protocol a listener carrying them speaks. + * + * ## What these pin, and the one thing they pin NEGATIVELY + * + * The ruling this card implements refused the generating shape — `--https` with + * a self-signed CA and printed instructions for installing it into a system + * trust store — on a security-statement ground: a product that tells developers + * to trust a certificate it minted owns that instruction, and an error inside it + * is invisible to the person following it. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化 + * 任何「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. + * + * `--help` is one of the surfaces that refusal covers, so the last describe + * block below reads the flag descriptions and the module's own source and + * asserts the absence. It is a real assertion rather than a restatement: the + * ANTI-VACUITY case proves the same scan finds the words that ARE there, so a + * future edit that adds trust-store prose cannot pass by having nothing to read. + */ + +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { describe, it, expect, afterAll } from 'vitest'; +import { + DEV_TLS_CERT_FLAG, + DEV_TLS_KEY_FLAG, + resolveDevTlsIntent, + listenerProtocol, + readDevTlsMaterial, + devTlsChildArgs, + devTlsCertFlag, + devTlsKeyFlag, + formatIncompleteDevTlsPairNotice, + formatUnreadableDevTlsFileNotice, +} from './dev-tls-contract.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CONTRACT_SOURCE = readFileSync(resolve(HERE, 'dev-tls-contract.ts'), 'utf8'); + +const scratch = mkdtempSync(join(tmpdir(), 'os-dev-tls-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +const write = (name: string, body: string): string => { + const p = join(scratch, name); + writeFileSync(p, body); + return p; +}; + +describe('resolveDevTlsIntent — three answers, never a boolean plus a validity bit', () => { + it('no flags: `none`, which is the shape every existing boot resolves to', () => { + expect(resolveDevTlsIntent({})).toEqual({ kind: 'none' }); + }); + + it('both flags: `requested`, carrying the two paths unchanged', () => { + expect(resolveDevTlsIntent({ cert: './c.pem', key: './k.pem' })).toEqual({ + kind: 'requested', + certPath: './c.pem', + keyPath: './k.pem', + }); + }); + + it('--cert alone is refused, and the notice names the MISSING half', () => { + const intent = resolveDevTlsIntent({ cert: './c.pem' }); + expect(intent.kind).toBe('incomplete'); + // The operator typed --cert; what they are told to add is --key. + expect(intent.kind === 'incomplete' && intent.notice).toContain('--cert was given without --key'); + }); + + it('--key alone is refused under its own spelling', () => { + const intent = resolveDevTlsIntent({ key: './k.pem' }); + expect(intent.kind).toBe('incomplete'); + expect(intent.kind === 'incomplete' && intent.notice).toContain('--key was given without --cert'); + }); + + describe('a value that did not expand reads as ABSENT, not as a path', () => { + // `--cert "$CERT"` with `CERT` unset reaches oclif as an empty string. Read + // as a path it refuses with `ENOENT ''`, which names neither the flag nor + // what the operator did. + it('empty on both: `none`', () => { + expect(resolveDevTlsIntent({ cert: '', key: ' ' })).toEqual({ kind: 'none' }); + }); + + it('empty on one: the incomplete-pair refusal, naming the half that IS set', () => { + const intent = resolveDevTlsIntent({ cert: './c.pem', key: '' }); + expect(intent.kind).toBe('incomplete'); + expect(intent.kind === 'incomplete' && intent.notice).toContain('--cert was given without --key'); + }); + }); +}); + +describe('listenerProtocol — https exactly when a whole pair was given', () => { + it('derives https from a requested pair', () => { + expect(listenerProtocol(resolveDevTlsIntent({ cert: './c.pem', key: './k.pem' }))).toBe('https'); + }); + + it('derives http with no flags at all', () => { + expect(listenerProtocol(resolveDevTlsIntent({}))).toBe('http'); + }); + + it('⛔ an INCOMPLETE pair never resolves to https', () => { + // The door that refuses the pair and the door that derives the origin read + // the same answer, so a refused boot can never have advertised https. + expect(listenerProtocol(resolveDevTlsIntent({ cert: './c.pem' }))).toBe('http'); + expect(listenerProtocol(resolveDevTlsIntent({ key: './k.pem' }))).toBe('http'); + }); +}); + +describe('readDevTlsMaterial — the bytes, or a refusal naming the flag and the path', () => { + it('reads both files, keeping the paths beside the bytes', () => { + const certPath = write('c.pem', 'CERT-BYTES'); + const keyPath = write('k.pem', 'KEY-BYTES'); + + const material = readDevTlsMaterial({ certPath, keyPath }); + + expect(material.cert.toString()).toBe('CERT-BYTES'); + expect(material.key.toString()).toBe('KEY-BYTES'); + expect(material).toMatchObject({ certPath, keyPath }); + }); + + it('a missing certificate throws, naming --cert and the path', () => { + const keyPath = write('k2.pem', 'KEY'); + const certPath = join(scratch, 'absent.pem'); + + expect(() => readDevTlsMaterial({ certPath, keyPath })).toThrow(/--cert could not be read/); + expect(() => readDevTlsMaterial({ certPath, keyPath })).toThrow(new RegExp(JSON.stringify(certPath).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + }); + + it('a missing key throws under ITS flag, not the certificate\'s', () => { + const certPath = write('c3.pem', 'CERT'); + const keyPath = join(scratch, 'absent-key.pem'); + + expect(() => readDevTlsMaterial({ certPath, keyPath })).toThrow(/--key could not be read/); + }); + + it('an unreadable-but-present key is refused too, carrying the OS reason', () => { + const certPath = write('c4.pem', 'CERT'); + const keyPath = write('k4.pem', 'KEY'); + chmodSync(keyPath, 0o000); + try { + // A root-run container can read a 000 file, in which case there is + // nothing to refuse — assert the two outcomes that are both correct + // rather than a permission model this test does not own. + let refusal: string | null = null; + try { + readDevTlsMaterial({ certPath, keyPath }); + } catch (e) { + refusal = (e as Error).message; + } + if (refusal !== null) { + expect(refusal).toContain('--key could not be read'); + expect(refusal).toContain('EACCES'); + } + } finally { + chmodSync(keyPath, 0o600); + } + }); + + it('⛔ offers no path back to plain http — the notice prescribes fixing the path', () => { + const notice = formatUnreadableDevTlsFileNotice(DEV_TLS_CERT_FLAG, '/no/such.pem', new Error('ENOENT')); + expect(notice).toContain('/no/such.pem'); + expect(notice).toContain('relative to the current working directory'); + // A developer who typed --cert asked for TLS. "Serving http instead" is the + // one answer that must never appear here. + expect(notice.toLowerCase()).not.toContain('falling back'); + expect(notice.toLowerCase()).not.toContain('plain http'); + }); +}); + +describe('devTlsChildArgs — what `os dev` forwards to its `serve` child', () => { + it('forwards both PATHS, so one process reads the file and owns the refusal', () => { + const intent = resolveDevTlsIntent({ cert: './c.pem', key: './k.pem' }); + expect(devTlsChildArgs(intent)).toEqual(['--cert', './c.pem', '--key', './k.pem']); + }); + + it('forwards nothing at all when no flags were given', () => { + expect(devTlsChildArgs(resolveDevTlsIntent({}))).toEqual([]); + }); + + it('⛔ forwards nothing for an incomplete pair — the parent already refused it', () => { + // Forwarding half a pair would have the child refuse it a second time, + // under the name of a channel the operator never used. + expect(devTlsChildArgs(resolveDevTlsIntent({ cert: './c.pem' }))).toEqual([]); + }); + + it('the forwarded spellings ARE the flag names the child declares', () => { + const args = devTlsChildArgs(resolveDevTlsIntent({ cert: 'c', key: 'k' })); + expect(args).toContain(`--${DEV_TLS_CERT_FLAG}`); + expect(args).toContain(`--${DEV_TLS_KEY_FLAG}`); + }); +}); + +describe('formatIncompleteDevTlsPairNotice — both halves, and the way out', () => { + it('names the two flags and says dropping both serves plain http', () => { + const notice = formatIncompleteDevTlsPairNotice(DEV_TLS_CERT_FLAG); + expect(notice).toContain('--cert '); + expect(notice).toContain('--key '); + expect(notice).toContain('Drop both to serve plain http on this port'); + }); + + it('carries no raw control bytes — colouring happens at the call site', () => { + for (const given of [DEV_TLS_CERT_FLAG, DEV_TLS_KEY_FLAG] as const) { + // eslint-disable-next-line no-control-regex + expect(formatIncompleteDevTlsPairNotice(given)).not.toMatch(/\[/); + } + }); +}); + +describe('⛔ NO CA generation and NO trust-store prose — the ruling, asserted', () => { + const surfaces: Array<[string, string]> = [ + ['--cert description', devTlsCertFlag().description ?? ''], + ['--key description', devTlsKeyFlag().description ?? ''], + ]; + + /** + * The words a generating implementation, or an instruction to trust one, + * cannot be written without. `keychain` / `certutil` / `security add-trusted` + * are the platform-specific spellings the card's own "Optionally print a + * one-line hint" sentence would have produced. + */ + const REFUSED = [ + 'trust store', 'trust-store', 'truststore', + 'keychain', 'certutil', 'add-trusted-cert', + 'self-signed', 'selfsigned', + 'generate a cert', 'generates a cert', 'generated cert', 'generated CA', + ]; + + it('no flag description offers to generate anything or to trust anything', () => { + for (const [name, text] of surfaces) { + const lower = text.toLowerCase(); + for (const word of REFUSED) { + expect(lower, `${name} must not mention ${JSON.stringify(word)}`).not.toContain(word.toLowerCase()); + } + } + }); + + it('--cert says the developer brings the certificate, in as many words', () => { + // The positive half: refusing the prose is only honest if the flag says + // whose job the certificate is. + expect(devTlsCertFlag().description).toContain('Bring your own certificate; none is generated.'); + }); + + it('the module generates no key material — no crypto/CA surface is reachable from it', () => { + // A generating implementation needs one of these. None is imported, and the + // ANTI-VACUITY case below proves this scan reads the file. + expect(CONTRACT_SOURCE).not.toContain('node:crypto'); + expect(CONTRACT_SOURCE).not.toContain('selfsigned'); + expect(CONTRACT_SOURCE).not.toContain('generateKeyPair'); + expect(CONTRACT_SOURCE).not.toMatch(/createCertificate|X509Certificate/); + }); + + describe('ANTI-VACUITY: the scan reads the module, and it read something', () => { + it('finds the words that ARE in the source', () => { + expect(CONTRACT_SOURCE).toContain('readFileSync'); + expect(CONTRACT_SOURCE).toContain('listenerProtocol'); + // And the refusal is stated in the module, not only in this test. + expect(CONTRACT_SOURCE).toContain('security-statement ground'); + }); + + it('would catch a trust-store sentence if one were added', () => { + const poisoned = `${CONTRACT_SOURCE}\n// add the CA to your trust store\n`; + expect(poisoned.toLowerCase()).toContain('trust store'); + expect(CONTRACT_SOURCE.toLowerCase()).not.toContain('add the ca to your trust store'); + }); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/adapter-tls-listener.test.ts b/packages/plugins/plugin-hono-server/src/adapter-tls-listener.test.ts new file mode 100644 index 0000000000..a967532d61 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/adapter-tls-listener.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * framework#16804 — the adapter binds a TLS listener when it is handed + * certificate material, and an ORDINARY one when it is not. + * + * ## Why this drives a real socket + * + * The whole question is what kind of server got created. `@hono/node-server` + * takes the factory as an option, so the diff is one branch — and a branch is + * exactly the shape a source-scanning test blesses without ever binding + * anything. So every case below listens on a real ephemeral port and speaks to + * it, and the two legs are driven against each other: the https leg must refuse + * a plain-http request and the http leg must refuse a TLS one. Either leg alone + * could pass by the server being broken in a way that happens to fail. + * + * ## The certificate is the TEST's, and it stays the test's + * + * It is minted here with `node:crypto` into a tempdir and deleted afterwards. + * ⛔ That is not a product capability and must not become one: the maintainer's + * ruling refused certificate generation in the shipped CLI on a + * security-statement ground. A test minting its own fixture makes no statement + * to anyone — nothing is printed, nothing is installed, and nothing outside + * this file can reach it. + */ + +import { X509Certificate, createPrivateKey, generateKeyPairSync } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { request as httpsRequest } from 'node:https'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { HonoHttpServer } from './adapter'; + +let scratch: string; +let cert: string; +let key: string; +/** Set when this box cannot mint a certificate; every case then refuses loudly. */ +let mintFailure: string | null = null; + +beforeAll(() => { + scratch = mkdtempSync(join(tmpdir(), 'os-hono-tls-')); + try { + // `openssl req -x509` is the one spelling available without a dependency: + // Node's crypto can generate the key pair but cannot self-sign an X.509 + // certificate. The key pair below is generated only to prove the toolchain + // is live before the shell call, so a failure is attributable. + generateKeyPairSync('rsa', { modulusLength: 2048 }); + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', join(scratch, 'key.pem'), + '-out', join(scratch, 'cert.pem'), + '-days', '1', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + ], { stdio: 'pipe' }); + cert = readFileSync(join(scratch, 'cert.pem'), 'utf8'); + key = readFileSync(join(scratch, 'key.pem'), 'utf8'); + } catch (e) { + mintFailure = e instanceof Error ? e.message : String(e); + } +}); + +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +interface TlsResponse { status: number; body: string } + +/** + * A GET over TLS that trusts THIS test's certificate and nothing else. + * + * ⛔ Not `NODE_TLS_REJECT_UNAUTHORIZED=0`, which disables verification for the + * whole process — including every sibling test sharing the worker — and would + * let the https leg pass against a certificate it never actually presented. + * Node's global `fetch` has no per-request `ca`, so this is `node:https` rather + * than a new dependency. + */ +const trustingGet = (port: number, path: string): Promise => + new Promise((resolve, reject) => { + const req = httpsRequest( + { host: 'localhost', port, path, method: 'GET', ca: cert, servername: 'localhost' }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on('error', reject); + req.end(); + }); + +describe('HonoHttpServer — a TLS listener from caller-supplied material (#16804)', () => { + it('the fixture minted, so a zero below is a reading rather than a dead probe', () => { + expect(mintFailure, `could not mint the test certificate: ${mintFailure}`).toBeNull(); + // And it really is a certificate for localhost — the negative cases below + // rest on the handshake failing for the RIGHT reason. + const x509 = new X509Certificate(cert); + expect(x509.subject).toContain('localhost'); + expect(() => createPrivateKey(key)).not.toThrow(); + }); + + it('speaks https, and the same fetch handler answers', async () => { + const server = new HonoHttpServer(0, undefined, 1000, { cert, key }); + server.getRawApp().get('/probe', (c) => c.json({ served: 'over-tls' })); + await server.listen(0); + const port = server.getPort(); + try { + expect(server.getProtocol()).toBe('https'); + + const res = await trustingGet(port, '/probe'); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ served: 'over-tls' }); + } finally { + await server.close(); + } + }); + + it('⛔ and REFUSES a plain-http request on that same port', async () => { + // The discriminating half. Without it, a listener that quietly stayed + // plain-http would pass the case above through an https URL that Node + // happened to downgrade. + const server = new HonoHttpServer(0, undefined, 1000, { cert, key }); + server.getRawApp().get('/probe', (c) => c.text('unreachable-over-http')); + await server.listen(0); + const port = server.getPort(); + try { + await expect(fetch(`http://127.0.0.1:${port}/probe`)).rejects.toThrow(); + } finally { + await server.close(); + } + }); + + it('ABLATION: without the material the SAME server is plain http', async () => { + // The leg that pins "nothing changes when the option is absent": identical + // construction but for the fourth argument, and the two legs disagree in + // both directions. + const server = new HonoHttpServer(0, undefined, 1000); + server.getRawApp().get('/probe', (c) => c.json({ served: 'over-http' })); + await server.listen(0); + const port = server.getPort(); + try { + expect(server.getProtocol()).toBe('http'); + + const res = await fetch(`http://127.0.0.1:${port}/probe`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ served: 'over-http' }); + + // …and it is not secretly a TLS listener either. + await expect(trustingGet(port, '/probe')).rejects.toThrow(); + } finally { + await server.close(); + } + }); + + it('drains on close exactly as the plain listener does', async () => { + // TLS must not cost the graceful-drain contract (`adapter-drain.test.ts` + // owns it for the http listener); the two share every line but the factory. + const server = new HonoHttpServer(0, undefined, 5000, { cert, key }); + server.getRawApp().get('/slow', async (c) => { + await new Promise((r) => setTimeout(r, 200)); + return c.text('drained-ok'); + }); + await server.listen(0); + const port = server.getPort(); + + const inFlight = trustingGet(port, '/slow'); + await new Promise((r) => setTimeout(r, 50)); + const closing = server.close(); + + const res = await inFlight; + expect(res.status).toBe(200); + expect(res.body).toBe('drained-ok'); + await closing; + }); + + it('the port walk still applies — a busy port shifts, over TLS too', async () => { + const first = new HonoHttpServer(0, undefined, 1000, { cert, key }); + first.getRawApp().get('/probe', (c) => c.text('first')); + await first.listen(0); + const taken = first.getPort(); + try { + const second = new HonoHttpServer(taken, undefined, 1000, { cert, key }); + second.getRawApp().get('/probe', (c) => c.text('second')); + await second.listen(taken); + try { + expect(second.getPort()).not.toBe(taken); + const res = await trustingGet(second.getPort(), '/probe'); + expect(res.body).toBe('second'); + } finally { + await second.close(); + } + } finally { + await first.close(); + } + }); +}); From dca1ad48905d4c518a7f3f32176152637ee3aab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:05:42 +0000 Subject: [PATCH 3/5] chore(changeset): dev-time TLS via developer-supplied cert/key (#16804) Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- .changeset/16804-dev-https-cert-key.md | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .changeset/16804-dev-https-cert-key.md diff --git a/.changeset/16804-dev-https-cert-key.md b/.changeset/16804-dev-https-cert-key.md new file mode 100644 index 0000000000..611a265333 --- /dev/null +++ b/.changeset/16804-dev-https-cert-key.md @@ -0,0 +1,44 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-hono-server': minor +--- + +feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) + +An interactive MCP client refuses to start an OAuth sign-in against a non-TLS +URL, so the self-serve identity path the product advertises — "interactive +clients just open a browser login" — could not be exercised against a local dev +server at all. The only way round it was a hand-built https reverse proxy plus +`OS_AUTH_URL`, a page of setup that every developer, demo and video recording +repeated off-camera. + +**Bring your own certificate.** Nothing here generates one, and nothing here — +not the code, not `--help`, not any doc page — says anything about installing a +certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 +「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the +developer's own business; this feature's whole job is to *use* the certificate +they already have. + +```bash +objectstack dev --cert ./localhost.pem --key ./localhost-key.pem +``` + +Both flags are required together — half a pair is refused by name — and an +unreadable file is refused rather than degraded to a plain-http listener. + +**What follows the listener.** With both flags given, everything this boot +advertises is `https://localhost:`: the two `/.well-known/*` discovery +documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the +`🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and +external supervisors dial. Only the built-in default at the end of the base-URL +chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, +an `http://` value included, because they name where a deployment is *reached* +rather than what this process *bound*. + +**Without the flags nothing changes**, byte for byte — pinned by ablation legs +rather than asserted. + +`@objectstack/plugin-hono-server` gains the option this is built on: +`HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS +listener with the same fetch handler, the same route table and the same graceful +drain. Absent, the listener is plain http exactly as before. From c6500241381ab229175feafc0eff951f81d9e865 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:20:21 +0000 Subject: [PATCH 4/5] fix(cli): write the ESC byte as its escape text in the dev-TLS pin test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:nul-bytes` caught a raw 0x1b that a scripted edit materialised out of the escape sequence while the test was asserting ABOUT that byte — the exact slip the gate's header says every occurrence in this repo came from. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- packages/cli/src/utils/dev-tls-contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/dev-tls-contract.test.ts b/packages/cli/src/utils/dev-tls-contract.test.ts index e1bbe10fe5..53756b4f59 100644 --- a/packages/cli/src/utils/dev-tls-contract.test.ts +++ b/packages/cli/src/utils/dev-tls-contract.test.ts @@ -206,7 +206,7 @@ describe('formatIncompleteDevTlsPairNotice — both halves, and the way out', () it('carries no raw control bytes — colouring happens at the call site', () => { for (const given of [DEV_TLS_CERT_FLAG, DEV_TLS_KEY_FLAG] as const) { // eslint-disable-next-line no-control-regex - expect(formatIncompleteDevTlsPairNotice(given)).not.toMatch(/\[/); + expect(formatIncompleteDevTlsPairNotice(given)).not.toMatch(/\u001b\[/); } }); }); From 03ba311254f02dd44567619d25b16d2640f0ccde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:25:09 +0000 Subject: [PATCH 5/5] docs(deployment): document the dev TLS flag pair, and correct the OS_AUTH_URL default row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hand-written pages, both found by a manual pass rather than by the docs-drift list — which is structurally blind here: a page that states a rule by its INPUTS shares no identifier with the emitter that implements it. - `environment-variables.mdx`'s `OS_AUTH_URL` row gave its default as `http://localhost:` unconditionally. This change makes that conditionally false, so it ships with the correction, and the row now also says the variable wins whatever the listener speaks. - `cli.mdx`'s `os dev` flag table enumerates every flag, so two new public flags absent from it would advertise a smaller CLI than ships. Added, plus a short section on what the pair is for. ⛔ Zero trust-store prose on either page, and nothing about obtaining or trusting a certificate beyond saying that both are the developer's own. ⛔ `content/docs/releases/` untouched. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 30 +++++++++++++++++++ .../docs/deployment/environment-variables.mdx | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 5b3b379e48..1bb4e17f6f 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -172,12 +172,42 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `--auth-secret ` | `OS_AUTH_SECRET` | Override the dev-fallback secret | | `--environment-id ` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) | | `-p, --port ` | `OS_PORT` / `PORT` | Listen port (default `3000`). In dev a busy port auto-hops to the next free one; the banner shows the actual port. | +| `--cert ` | — | Path to a TLS certificate (PEM). With `--key`, terminate TLS in the dev process and serve `https://localhost:`. Bring your own certificate — none is generated | +| `--key ` | — | Path to the private key (PEM) for `--cert`. Required with `--cert` | | `--ui` | — | Force Console UI on (already on by default in dev) | | `--compile` | — | Force compiling `objectstack.config.ts` → `dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) | | `--fresh` | — | Ephemeral `OS_HOME` in the OS tempdir (clean DB, uploads root, and other `OS_HOME`-keyed state), auto-deleted on exit; implies `--seed-admin`. See the scope note below | | `--seed-admin` / `--no-seed-admin` | — | Seed a dev admin (`admin@objectos.ai` / `admin123`) on an empty DB — default on; override with `--admin-email` / `--admin-password` | | `-v, --verbose` | — | Verbose output | +##### Serving dev over https + +An interactive MCP client (and any OAuth client worth the name) refuses to open a +sign-in against a plain-http URL, so the self-serve identity path — *interactive +clients just open a browser login* — cannot be exercised against a dev server on +`http://localhost`. Hand `os dev` a certificate you already have and it +terminates TLS itself: + +```bash +os dev --cert ./localhost.pem --key ./localhost-key.pem +``` + +Both flags are required together, and an unreadable file is refused rather than +quietly downgraded to http. With them, every address this boot advertises is +`https://localhost:`: the two `/.well-known/*` discovery documents, the +CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the `🤖 MCP server` +connect hint, and the runtime state file a supervisor reads. Without them nothing +changes. + +Only the built-in default follows the listener. `OS_AUTH_URL` (and +`OS_BASE_URL`) still win when set — they name where the deployment is *reached*, +which behind a proxy or a tunnel is a different address from the one this process +bound — so an explicit value is never rewritten, `http://` ones included. + +Where the certificate comes from, and which certificates your client or your +machine accepts, is yours to decide: ObjectStack generates none and reads no +store. + By default `os dev` keeps your data between restarts in a project-local SQLite file at `.objectstack/data/dev.db` (created on first run). Pass `--database`, set `OS_DATABASE_URL`, or use `--fresh` for a throwaway run. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index a9b8096114..1929832643 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -74,7 +74,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | Variable | Type | Default | Description | |:---|:---|:---|:---| -| `OS_AUTH_URL` | url | `http://localhost:` | Public base URL of the auth server. Required behind a proxy or in production. | +| `OS_AUTH_URL` | url | `http://localhost:`, or `https://localhost:` when `os dev` terminates TLS (`--cert` / `--key`) | Public base URL of the auth server. Required behind a proxy or in production. When set it always wins, whatever the listener speaks — it names where the deployment is reached, not what the process bound. | | `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. | | `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. | | `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |