Skip to content

Commit 89a652b

Browse files
claude[bot]claude
andauthored
feat(cli): objectstack dev --cert/--key terminates TLS in the dev process, and the canonical origin follows the listener (#17725)
Fixes #16804 **objectstack dev** `--cert` &lt;path&gt; `--key` &lt;path&gt; terminates TLS in the dev process itself, and every origin the boot advertises follows the listener. This is director-seat ruling [`5617187807`](#16804 (comment)), batch #111 item 1, option **N (narrow)** — parts (1)+(2) of the card's Ask in exactly that shape. ## ⛔ No CA generation, and no trust-store prose — 不生成 CA、不写信任库指引 The ruling, quoted verbatim (⛔ not paraphrased — 引用中文裁决时保持原文): > **裁定**:objectstack dev --cert &lt;path&gt; --key &lt;path&gt;:开发者自带证书,dev 进程内终止 TLS;同笔做 (2):规范 origin 自动为 `https://localhost:<port>`,两个 `.well-known/*` 文档随之广播 https,`OS_AUTH_URL` 只作覆盖(`resolveAuthBaseUrl` 的硬编码 `http://` 回落尾巴按 listener 协议派生)。⛔ 不生成自签 CA;⛔ 不打印、不文档化任何「把 CA 装进系统信任库」的指引——信任库是开发者自己的事。 So, in as many words: **this PR generates no certificate and no CA, and it writes no instruction anywhere — not in code, not in `--help`, not in a doc page, and not as a suggestion in this body — for installing a certificate into a system trust store.** The trust store is the developer's own business. The developer brings the certificate; the feature's whole job is to *use* it. Option **F** (generated CA + trust instructions) is refused on the ruling's security-statement ground; option **X** (document the proxy recipe) is not delivered. That refusal is **asserted, not merely promised**: `dev-tls-contract.test.ts`'s last `describe` reads both flag descriptions and the module's own source for `trust store` / `keychain` / `certutil` / `add-trusted-cert` / `self-signed` / `generated CA` and for `node:crypto`, `generateKeyPairSync`, `X509Certificate`, and an ANTI-VACUITY case proves the same scan finds words that *are* there — so a future edit adding that prose cannot pass by having nothing to read. ## The confidence gap the ruling handed the implementing seat — MEASURED > 置信缺口留给实施席先测:Hono 适配层接 Node TLS 的成本(`packages/cli/src` 今天零处 TLS 代码),超出 M 级停手回报。 **Reading: XS.** `@hono/node-server@2.1.1` takes the listener factory as an *option*. Its exported `Options` type is a union whose https arm is, verbatim from `node_modules/@hono/node-server/dist/index.d.mts`: ```ts type createHttpsOptions = { serverOptions?: ServerOptions$2; // node:https ServerOptions createServer?: typeof createServer$2; // node:https createServer }; ``` So terminating TLS needs **no bridging code at all** — the same `fetch` handler, the same route table, the same graceful drain, one different server factory. Well under an M; no stop was warranted. ## Premise check — all four re-derived on this branch's base `6fa2a8ae`, all four hold | # | premise | re-derived here | |:--|:--|:--| | 1 | `resolveAuthBaseUrl` has a hardcoded `http://` fallback tail | ✓ `packages/cli/src/commands/serve.ts:5634` declared it; `:5637` was ``?? `http://localhost:${port}` `` | | 2 | `packages/cli/src` contains **zero** TLS code | ✓ `https.createServer` · `createSecureServer` · `node:tls` · `node:https` → **0** each. Positive control from the same tree: `createServer` → 3 hits (`commands/serve.ts:255` — a `net.createServer()` port probe — plus `utils/port-contract.ts:52` and `serve-port-validation.test.ts:96`, both in prose), `from 'node:` → 313 | | 3 | `dev` declares no `--cert` / `--key` | ✓ zero on `dev.ts` + `serve.ts`; `dev`'s flags are at `commands/dev.ts:128` (`static override flags`). The only `cert`-shaped hits in `serve.ts` were the word "certainly" at `:410` and `/.well-known/*` prose at `:5036` | | 4 | part (3) is already fixed by #16734 / PR #16812 | ✓ `printMcpConnectHint` already resolves through `resolveAuthBaseUrl`; **not** re-implemented here | `packages/plugins/plugin-hono-server/src/adapter.ts` likewise had **zero** `https` hits, against a same-file positive control of 8 for `key` — so the TLS arm is genuinely new rather than a second copy of one. ## What follows the listener, and what deliberately does not `resolveAuthBaseUrl(port, boundProtocol)` — **only the built-in default tail moves.** That tail is the one link in the chain nobody configured: it is the process describing its own socket, and once TLS terminates in-process http://localhost:&lt;port&gt; is an address no client can reach. ⛔ Every **configured** link keeps winning — `OS_AUTH_URL`, the legacy `BETTER_AUTH_URL`, `OS_BASE_URL` — **an `http://` value included.** They answer a different question: where the deployment is *reached*, which behind a proxy or a tunnel has no relation to what this process bound. A default has no standing to overrule an operator's deliberate statement about a different hop, and a "helpful" scheme upgrade there would be a bug wearing a feature's clothes. Because the whole boot already routed through that one resolver, the three acceptance surfaces follow with no second reader: the two `/.well-known/*` documents (`AuthManager.getCanonicalOrigin()` ← `AuthPlugin({ baseUrl })` ← `resolveAuthBaseUrl`), the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, and the `🤖 MCP server` block. `publishBoundPort(..., boundProtocol)` is the **socket's own** address rather than the canonical origin, so it is ⛔ *not* `OS_AUTH_URL`-overridable: both of its consumers *open* that url — the runtime state file is what an external supervisor dials, the IPC message is what the `os dev` parent learns the server from — and a hardcoded `http://` under a TLS listener hands both a machine-readable address that answers a handshake error. This change is what makes that surface false, so it ships with it. ## Acceptance — driven on a real boot of `examples/app-todo`, bytes pasted ### ① `--cert`/`--key`, `OS_AUTH_URL` unset — all three surfaces give https `node packages/cli/bin/run.js dev --fresh -p 38471 --cert CERTPATH --key KEYPATH` (CERTPATH / KEYPATH are the two real file paths; spelled as words because a bracketed placeholder does not survive a GitHub body write): ```text ➜ API: https://localhost:38471/ ➜ MCP: https://localhost:38471/api/v1/mcp connect an AI client (Claude Code, Cursor, …) · skill: https://localhost:38471/api/v1/mcp/skill 🤖 MCP server — connect a coding agent: Endpoint https://localhost:38471/api/v1/mcp Skill https://localhost:38471/api/v1/mcp/skill Connect claude mcp add --transport http app-todo https://localhost:38471/api/v1/mcp Disable OS_MCP_SERVER_ENABLED=false ``` `GET /.well-known/oauth-protected-resource` over that real TLS socket: ```json { "resource": "https://localhost:38471/api/v1/mcp", "authorization_servers": ["https://localhost:38471/api/v1/auth"], "scopes_supported": ["data:read", "data:write", "actions:execute", "offline_access"], "bearer_methods_supported": ["header"], "resource_name": "ObjectStack MCP" } ``` `GET /.well-known/oauth-authorization-server` (url-valued fields): ```json { "issuer": "https://localhost:38471/api/v1/auth", "authorization_endpoint": "https://localhost:38471/api/v1/auth/oauth2/authorize", "token_endpoint": "https://localhost:38471/api/v1/auth/oauth2/token", "jwks_uri": "https://localhost:38471/api/v1/auth/jwks", "registration_endpoint": "https://localhost:38471/api/v1/auth/oauth2/register" } ``` And the listener really is TLS — plain http on that same port, with its exit code captured **before** any pipe: ```text $ curl -sS --noproxy '*' -m 10 http://localhost:38471/.well-known/oauth-protected-resource > log 2>&1; echo "EXIT=$?" EXIT=52 curl: (52) Empty reply from server ``` The runtime state file names the socket, not a guess: `{"pid":27532,"port":38471,"url":"https://localhost:38471",...}`. ### ② Without the flags — identical to today Same command, flags removed, port 38472: ```text 🤖 MCP server — connect a coding agent: Endpoint http://localhost:38472/api/v1/mcp Skill http://localhost:38472/api/v1/mcp/skill Connect claude mcp add --transport http app-todo http://localhost:38472/api/v1/mcp ➜ API: http://localhost:38472/ ➜ MCP: http://localhost:38472/api/v1/mcp ``` ```json { "resource": "http://localhost:38472/api/v1/mcp", "authorization_servers": ["http://localhost:38472/api/v1/auth"] } ``` state file: `{"port":38472,"url":"http://localhost:38472",...}` ⭐ **Pinned as an ablation, not a claim** — three legs, each requiring the omitted argument and an explicit `http` to be *identical* while both differ from `https`: - `dev-mcp-connect-hint-origin.test.ts` — `bootWithoutProtocolArg()` reproduces this file's pre-change call expression character for character and drives it beside today's call; the whole captured boot buffer (banner + hint, `console.error` and `console.log` in call order) must match byte for byte, over a plain port, dev's auto-shifted port and an ephemeral one. - `serve-auth-base-url-diagnostic.test.ts` — `resolveAuthBaseUrl(port)` must equal `resolveAuthBaseUrl(port, 'http')` and differ from `resolveAuthBaseUrl(port, 'https')`. - `serve-bound-port-publication.test.ts` — the same, for the state-file payload and the IPC message. - `adapter-tls-listener.test.ts` — the http and https listeners are constructed identically but for the fourth argument, and each **refuses** the other's protocol, so neither leg can pass by being broken in a convenient direction. ### ③ `OS_AUTH_URL` still wins `OS_AUTH_URL=https://tunnel.example.com` **with** both TLS flags, port 38473: ```text ➜ API: https://tunnel.example.com/ ➜ MCP: https://tunnel.example.com/api/v1/mcp Endpoint https://tunnel.example.com/api/v1/mcp Connect claude mcp add --transport http app-todo https://tunnel.example.com/api/v1/mcp ``` ```json { "resource": "https://tunnel.example.com/api/v1/mcp", "authorization_servers": ["https://tunnel.example.com/api/v1/auth"] } ``` …while the state file still names the socket: `{"port":38473,"url":"https://localhost:38473",...}` — the canonical origin and the bound address answering their own questions, as designed. The awkward direction is pinned too: an `http://` `OS_AUTH_URL` under a TLS listener is **not** upgraded. ### Refusals — loud, and never degraded to plain http ```text $ os dev --cert CERTPATH # EXIT=1 ✗ --cert was given without --key. TLS needs both halves: --cert CERT-PATH-PLACEHOLDER --key KEY-PATH-PLACEHOLDER. Drop both to serve plain http on this port. $ os dev --cert /no/such/cert.pem --key /no/such/key.pem # EXIT=1 ✗ --cert could not be read: "/no/such/cert.pem" ENOENT: no such file or directory, open '/no/such/cert.pem' The path is resolved relative to the current working directory. ``` ⚠️ In the first block the notice's two bracketed placeholders are rendered here as CERT-PATH-PLACEHOLDER / KEY-PATH-PLACEHOLDER. The bytes the CLI actually prints are angle-bracketed (`path to the certificate` and `path to its private key` inside angle brackets) and are pinned verbatim in `dev-tls-contract.test.ts`; a bracketed span does not survive a GitHub body write, so it is spelled out rather than silently eaten. ⛔ There is deliberately no path from either refusal back to an http listener: a developer who typed `--cert` asked for TLS, and answering with the other protocol would surface first as a client-side handshake error naming neither the flag nor the file. Prefer failing to falling back. ## Shape of the change `packages/cli/src/utils/dev-tls-contract.ts` is the **one** reader of the pair, shared by `dev` and the `serve` child it spawns — the same judgement as `port-contract.ts`, for the same reason: before it, a value typed at `dev` would have been refused one process later under the name of the channel it arrived on. `dev` forwards the **paths**, never the bytes, so exactly one process reads the file and exactly one owns that refusal — and the parent's `boundProtocol` comes from the same `resolveDevTlsIntent` call that built the child's argv, so the scheme the hint prints and the scheme the child binds cannot part company (pinned: exactly one `resolveDevTlsIntent(` call in `dev.ts`). `HonoPluginOptions.tls` takes PEM **bytes**, ⛔ not paths: whoever hands the adapter TLS material is the layer that knows why it has it and can name the flag the operator typed. A transport adapter taking paths would own a second reader of the same file and would have to invent a refusal from a filename. ## Scope — declared growth, and two fences confirmed clear The dispatch's declared face was `commands/dev.ts`, `commands/serve.ts` and their tests. The delivered diff **grew** into `packages/plugins/plugin-hono-server` (`adapter.ts`, `hono-plugin.ts`, one new test) and `packages/cli/src/utils`, because `serve()` — the only place a listener is created — lives in the adapter, and putting TLS anywhere else would have meant a second listener owner (Route & surface ownership §1). Contract-first: the option belongs at the producer. Checked rather than assumed: **no open PR holds any file in this diff.** All 9 open PRs' file lists were read; PR #17454's cli files are `index.ts`, `commands/init.ts`, `commands/migrate/{account-issuer,apply}.ts` — disjoint from every path here — and no open PR touches `plugin-hono-server` at all. ⛔ No `packages/spec` edit: `HonoPluginOptions` is declared locally in `hono-plugin.ts`, so the new option needed none (`packages/spec` was read only). ⛔ `content/docs/releases/` untouched. `Clause-②` re-derived from the **delivered** diff: still **yes** — two new public CLI flags on two commands, a new exported module, a widened exported `resolveAuthBaseUrl` / `publishBoundPort` signature (both additive defaults), and a new public `HonoPluginOptions.tls`. ## Two pins this change moved, and why they are updated rather than deleted `serve-bound-port-publication.test.ts` held three source-text pins that went red on the first run — exactly their job. Each is updated to the new spelling with its intent intact: the banner pin still asserts the origin comes from `boundPort` (not `port`), the publish-site pin still asserts the seam is handed the resolved bound port, and the positive control for the `not.toContain` negatives still asserts `port` reaches the transport under its own name — now by reading the construction site rather than one formatting of it. ## The acceptance-2 pins CAN fail — proven by mutation, not by inspection An ablation leg only means something if it goes red when the thing it guards moves. Driven from the committed state, on disk, with the restore in a `trap`: **Mutation.** Both `boundProtocol: ListenerProtocol = 'http'` defaults — `resolveAuthBaseUrl`'s and `publishBoundPort`'s; there are exactly two, and the script refuses to proceed on any other count — flipped to `'https'`, which is precisely the "derived protocol leaked into the default" regression. ```text HEAD blob: 8afd9f4 before: old-spelling=2 new-spelling=0 after: old-spelling=0 new-spelling=2 <- the edit REACHED THE DISK mutated blob: 2ae1e42c1400fcb736763b5e18243554198f685a MUTATED_EXIT=1 Test Files 2 failed (2) Tests 5 failed | 48 passed (53) restored blob: 8afd9f4 <- byte-identical to HEAD git diff HEAD: empty; git status --porcelain: [] RESTORED_EXIT=0 Test Files 2 passed (2) Tests 53 passed (53) ``` ⚠️ Observed direction: **turns red** — the ordinary one. The five that failed: ```text × resolveAuthBaseUrl — precedence (pre-existing behaviour, unchanged) > falls back to http://localhost:<port> when no variable is set × resolveAuthBaseUrl — the listener protocol reaches the TAIL and stops there > ABLATION: the omitted argument and an explicit `http` are the same call × without the flags the output is byte-for-byte what it was > the no-flag boot equals the boot that never passes a protocol at all > the two legs also agree on an auto-shifted port and an ephemeral one > and the legs DISCRIMINATE — the https leg differs from both ``` ⭐ The first of those five is **not one of this PR's tests** — it is the pre-existing #10202 pin, which has guarded that fallback since before `--cert` existed. A mutation that reddens a pin written years before the feature is the cleanest available evidence that acceptance 2 is guarded by the tree and not merely by this PR's own assertions. Discipline notes, since each is a way this measurement could have been void: the restore is `git checkout HEAD -- <absolute path>` (⛔ never bare `git checkout --`, which restores the mutation back out of the index); it is verified by `git diff HEAD` being empty and by blob equality, ⛔ never by an exit code; the `trap … EXIT INT TERM` uses an absolute path resolved from `git rev-parse --show-toplevel`; and an empty or unresolvable blob hash is treated as a FAILURE rather than as "nothing to compare". No mutated file survives — the final tree is clean at `03ba3112`. ## Gates — every derived family measured, at `03ba3112` The set was **derived from the delivered diff**, not taken from the dispatch's list: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the docs commit (13 paths → **95** commands, 30 of them families only the two `content/docs/` paths schedule). Reconciled back: ```text Run reconciliation — 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. ``` **95 / 95 green.** Four needed a second pass, and each one is worth naming: | family | first pass | why, and what closed it | |:--|:--|:--| | `check:nul-bytes` | **exit 1 — a real finding** | A scripted edit had materialised `\u001b` into a raw `0x1b` byte in `dev-tls-contract.test.ts:209`, in the very assertion that is *about* that byte — the exact slip the gate's header says every occurrence in this repo came from. Fixed to the escape text in `c6500241`; now `OK (scanned 8438 text file(s) … no raw ASCII control bytes)`, plus a `grep -naP` self-scan over every changed file returning empty | | `check:dual-build-cjs-loads` | exit 3 | `PREREQUISITE NOT MET` — unbuilt sibling packages, ⛔ not a pass and ⛔ not a red. Re-run after the closure builds: `✓ 104 published require entry point(s) across 67 package(s) load; 620 emitted CommonJS file(s) parse` | | `check:i18n-coverage` | exit 3 | Same class — `os lint` could not load `app-showcase`'s config against an unbuilt `connector-mcp`, so *nothing was compared*. Re-run: `OK (13 config(s), 621 baselined untranslated string(s), none new)` | | `check:type-check-debt` | exit 3 | The `--re-measure` leg OOM'd — under **my own** `NODE_OPTIONS=--max-old-space-size=4096`, which is *below* the 6144 MB CI-shaped ceiling the gate pins for itself. It refused to record 0 rather than lying. Re-run at 8192: `OK — 5 ledger entr(ies) re-measured in 98.2s, 55 raw tsc error(s) total, none above its recorded number` | ⚠️ An `exit 3` from any of those three is `PREREQUISITE NOT MET` — **NOT MEASURED**, in neither direction — so none of them was reported green until it had actually run. **`pnpm lint` — the full union, not a narrowing.** The lane adds it and `dispatch-gates.mjs` does not name it. It completed over the whole repo at the final commit `03ba3112` (clean tree, `git status --porcelain` empty): ```text node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json → 6638 files linted · 0 errors · 0 warnings (exit 0) ``` The 10 changed `.ts` files also lint clean on their own (`--format json` → 10 files, 0/0). Since the union ran, no invariance argument is owed; for the record, this repo's single `eslint.config.mjs` enables **no** type-aware linting (zero `parserOptions.project` / `projectService` matches — the config says so itself at `:328`), so a diff here cannot move an untouched file's verdict anyway. **Build / typecheck / tests**, all through `scripts/pm/os-verify-lock.sh` (one lock per container): | run | verdict | |:--|:--| | `pnpm --filter '@objectstack/plugin-hono-server^...' --filter '@objectstack/cli^...' build` | `VERDICT command-exit 0` (held 453s) | | the two packages' own `build` + `typecheck` | `VERDICT command-exit 0` | | `plugin-hono-server` — `adapter-tls-listener` + `adapter-drain` | `2 passed` files, **8 passed** tests | | `cli --project unit` — the four pin files | `4 passed` files, **102 passed** tests | `packages/cli`'s `integration` tier is **declared to CI**: no path in this diff is an integration-tier file, a `bin/` entry or `test/helpers/serve-process.ts`, so `--project unit` is what is owed locally. ## Acceptance notes - `check:nul-bytes` caught a real defect in this PR before it was pushed: a Python-driven edit materialised `�` into a raw `0x1b` byte in `dev-tls-contract.test.ts`. Fixed to the escape text; gate re-run green over 8438 files, plus a `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` self-scan over every changed file returning empty. - **noted, not filed** — `publishBoundPort`'s url is `localhost`-hardcoded on the host half as well as the scheme half. Under a non-loopback bind that host is already wrong today, independently of TLS; this change touches only the scheme, and the host is out of this card's scope. Carrier: whoever next changes the bind host — no PR or card currently in flight names it. - **noted, not filed** — `AuthManager.getCanonicalOrigin()`'s own fallback is `'http://localhost:3000'` (`auth-manager.ts:6088`), reached only when no `baseUrl` is configured at all. Every `os serve` / `os dev` path passes one, so this branch is unreachable from the CLI and no TLS boot can hit it. Not a defect from any door this card opens. Carrier: whoever composes an `AuthPlugin` without a `baseUrl`. --- ## 维护者速读(草稿) **改了什么。** `objectstack dev --cert <证书> --key <私钥>` 两个新 flag:开发者自带证书,dev 进程自己终止 TLS。给了这两个 flag 之后,这次启动对外宣告的所有地址一律变成 `https://localhost:<端口>`——两个 `/.well-known/*` 发现文档、CSRF 白名单、就绪横幅的 `API:`/`MCP:` 行、`🤖 MCP server` 连接提示,以及外部监管进程会去拨的 runtime 状态文件。不给 flag 时逐字节和今天一样。 **为什么改。** 桌面端 MCP 客户端拒绝对非 https 地址发起 OAuth 登录,所以产品页面承诺的「交互式客户端自动弹浏览器登录」在本地开发服务器上根本演示不了。以前唯一的走法是镜头外手搭一页 openssl + 反向代理再手设 `OS_AUTH_URL`——每次演示、每次录屏、每次排查都要重来一遍。这一笔把那页准备工作删掉。 **风险与代价(含回滚)。** ⛔ 不生成任何证书或 CA,⛔ 也不在任何地方(代码、`--help`、文档、本 PR 正文)写「把 CA 装进系统信任库」的指引——信任库仍然是开发者自己的事,这条由测试反向断言把守,将来有人加这句话会红。dev 进程在给了 flag 时持有一份私钥,这是自带证书方案本来就有的性质,不新增。风险面很窄:不给 flag 时代码路径与今天完全相同,已由四处消融测试钉住。回滚 = revert 本 PR,无数据迁移、无配置残留、无已发布键退役。已配置的 `OS_AUTH_URL` 一律优先(连 `http://` 的值也不被「升级」),所以任何现有部署的行为不动。 **席位意见。** _(留空,待维护者定稿)_ **你要做的。** 这是新公开 CLI 面(`Clause-②: yes`),已挂 `needs:contract-review`。请确认两点:① 两个 flag 的名字与描述文案;② 「只有回落尾巴跟随 listener、所有已配置值一律优先」这条边界是你要的。确认后按常规合并即可。 This pull request was authored by Claude Code in session `session_01TSf4DV7ziu4V5j73e46b7c`; that sentence is the durable attribution, kept in prose because a PR body's footer block is not reliably preserved by the platform. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe71032 commit 89a652b

13 files changed

Lines changed: 1258 additions & 26 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/plugin-hono-server': minor
4+
---
5+
6+
feat(cli): `objectstack dev --cert <path> --key <path>` terminates TLS in the dev process, and the canonical origin follows the listener (#16804)
7+
8+
An interactive MCP client refuses to start an OAuth sign-in against a non-TLS
9+
URL, so the self-serve identity path the product advertises — "interactive
10+
clients just open a browser login" — could not be exercised against a local dev
11+
server at all. The only way round it was a hand-built https reverse proxy plus
12+
`OS_AUTH_URL`, a page of setup that every developer, demo and video recording
13+
repeated off-camera.
14+
15+
**Bring your own certificate.** Nothing here generates one, and nothing here —
16+
not the code, not `--help`, not any doc page — says anything about installing a
17+
certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何
18+
「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the
19+
developer's own business; this feature's whole job is to *use* the certificate
20+
they already have.
21+
22+
```bash
23+
objectstack dev --cert ./localhost.pem --key ./localhost-key.pem
24+
```
25+
26+
Both flags are required together — half a pair is refused by name — and an
27+
unreadable file is refused rather than degraded to a plain-http listener.
28+
29+
**What follows the listener.** With both flags given, everything this boot
30+
advertises is `https://localhost:<port>`: the two `/.well-known/*` discovery
31+
documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the
32+
`🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and
33+
external supervisors dial. Only the built-in default at the end of the base-URL
34+
chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning,
35+
an `http://` value included, because they name where a deployment is *reached*
36+
rather than what this process *bound*.
37+
38+
**Without the flags nothing changes**, byte for byte — pinned by ablation legs
39+
rather than asserted.
40+
41+
`@objectstack/plugin-hono-server` gains the option this is built on:
42+
`HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS
43+
listener with the same fetch handler, the same route table and the same graceful
44+
drain. Absent, the listener is plain http exactly as before.

content/docs/deployment/cli.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,42 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
172172
| `--auth-secret <s>` | `OS_AUTH_SECRET` | Override the dev-fallback secret |
173173
| `--environment-id <id>` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) |
174174
| `-p, --port <n>` | `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. |
175+
| `--cert <path>` || Path to a TLS certificate (PEM). With `--key`, terminate TLS in the dev process and serve `https://localhost:<port>`. Bring your own certificate — none is generated |
176+
| `--key <path>` || Path to the private key (PEM) for `--cert`. Required with `--cert` |
175177
| `--ui` || Force Console UI on (already on by default in dev) |
176178
| `--compile` || Force compiling `objectstack.config.ts``dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) |
177179
| `--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 |
178180
| `--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` |
179181
| `-v, --verbose` || Verbose output |
180182

183+
##### Serving dev over https
184+
185+
An interactive MCP client (and any OAuth client worth the name) refuses to open a
186+
sign-in against a plain-http URL, so the self-serve identity path — *interactive
187+
clients just open a browser login* — cannot be exercised against a dev server on
188+
`http://localhost`. Hand `os dev` a certificate you already have and it
189+
terminates TLS itself:
190+
191+
```bash
192+
os dev --cert ./localhost.pem --key ./localhost-key.pem
193+
```
194+
195+
Both flags are required together, and an unreadable file is refused rather than
196+
quietly downgraded to http. With them, every address this boot advertises is
197+
`https://localhost:<port>`: the two `/.well-known/*` discovery documents, the
198+
CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the `🤖 MCP server`
199+
connect hint, and the runtime state file a supervisor reads. Without them nothing
200+
changes.
201+
202+
Only the built-in default follows the listener. `OS_AUTH_URL` (and
203+
`OS_BASE_URL`) still win when set — they name where the deployment is *reached*,
204+
which behind a proxy or a tunnel is a different address from the one this process
205+
bound — so an explicit value is never rewritten, `http://` ones included.
206+
207+
Where the certificate comes from, and which certificates your client or your
208+
machine accepts, is yours to decide: ObjectStack generates none and reads no
209+
store.
210+
181211
By default `os dev` keeps your data between restarts in a project-local SQLite
182212
file at `.objectstack/data/dev.db` (created on first run). Pass `--database`,
183213
set `OS_DATABASE_URL`, or use `--fresh` for a throwaway run.

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
7474

7575
| Variable | Type | Default | Description |
7676
|:---|:---|:---|:---|
77-
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
77+
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>`, or `https://localhost:<OS_PORT>` 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. |
7878
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
7979
| `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. |
8080
| `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. |

packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,28 @@ describe('os dev MCP connect hint — origin (#16734)', () => {
8989
* child's banner (its own call site's expression, verbatim) and then the
9090
* parent's connect hint, both told the port the server ACTUALLY bound.
9191
*/
92-
const boot = (boundPort: number, name = 'hotcrm') => {
92+
const boot = (boundPort: number, name = 'hotcrm', boundProtocol: 'http' | 'https' = 'http') => {
93+
printServerReady({
94+
...bannerOpts,
95+
externalBaseOrigin: resolveAuthBaseUrl(boundPort, boundProtocol).baseOrigin,
96+
});
97+
printMcpConnectHint({ boundPort, name, boundProtocol });
98+
return lines.join('\n');
99+
};
100+
101+
/**
102+
* The same boot with the protocol argument WITHHELD from both printers — the
103+
* expression this file carried before `--cert`/`--key` existed, character for
104+
* character.
105+
*
106+
* ⭐ This is the ABLATION LEG for acceptance 2 (「无 flag 时逐字节等于今天」,
107+
* #16804). The pins below drive it beside {@link boot}'s no-flag case and
108+
* require the two to be byte-identical, so the claim "nothing changes without
109+
* the flags" is measured rather than asserted: it would go red the day the
110+
* derived protocol leaked into the default, and it cannot pass by both legs
111+
* being broken in the same direction.
112+
*/
113+
const bootWithoutProtocolArg = (boundPort: number, name = 'hotcrm') => {
93114
printServerReady({ ...bannerOpts, externalBaseOrigin: resolveAuthBaseUrl(boundPort).baseOrigin });
94115
printMcpConnectHint({ boundPort, name });
95116
return lines.join('\n');
@@ -203,10 +224,117 @@ describe('os dev MCP connect hint — origin (#16734)', () => {
203224
});
204225
});
205226

227+
// ── #16804 ─ a TLS listener, and the three surfaces that follow it ──────
228+
describe('under --cert/--key the derived origin is https, and everything follows', () => {
229+
it('acceptance 1: with OS_AUTH_URL UNSET, banner and hint both give https', () => {
230+
// Every chain variable is deleted by `beforeEach`, so the only thing
231+
// that can produce `https` here is the listener protocol reaching the
232+
// resolver's built-in tail.
233+
const output = boot(3000, 'hotcrm', 'https');
234+
235+
expect(output).toContain('MCP: https://localhost:3000/api/v1/mcp');
236+
expect(output).toContain('Endpoint https://localhost:3000/api/v1/mcp');
237+
expect(output).toContain('Skill https://localhost:3000/api/v1/mcp/skill');
238+
expect(output).toContain(
239+
'Connect claude mcp add --transport http hotcrm https://localhost:3000/api/v1/mcp',
240+
);
241+
// The plain-http address must not appear anywhere in a TLS boot's output:
242+
// it is the one address a client on this port cannot reach.
243+
expect(output).not.toContain('http://localhost:3000');
244+
expect(mcpOrigins(output)).toEqual(['https://localhost:3000']);
245+
});
246+
247+
it("follows dev's auto-shifted port under TLS too", () => {
248+
expect(mcpOrigins(boot(3001, 'hotcrm', 'https'))).toEqual(['https://localhost:3001']);
249+
});
250+
251+
it('acceptance 3: `OS_AUTH_URL` still WINS over the derived https origin', () => {
252+
// The override direction that matters in practice: a developer
253+
// terminating TLS locally but reached through a tunnel on another host.
254+
process.env.OS_AUTH_URL = 'https://tunnel.example.com';
255+
expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['https://tunnel.example.com']);
256+
});
257+
258+
it('acceptance 3, the awkward direction: an http OS_AUTH_URL wins as well', () => {
259+
// ⛔ Deliberately NOT "upgraded" to https. `OS_AUTH_URL` names where the
260+
// deployment is REACHED — behind a TLS-terminating proxy that forwards
261+
// plain http, or in a test harness, that is a deliberate statement about
262+
// a different hop, and a default has no standing to overrule it.
263+
process.env.OS_AUTH_URL = 'http://proxied.example.com';
264+
expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['http://proxied.example.com']);
265+
});
266+
267+
it('the rest of the configured chain keeps its precedence under TLS', () => {
268+
process.env.OS_BASE_URL = 'https://base.example.com';
269+
expect(mcpOrigins(boot(3000, 'hotcrm', 'https'))).toEqual(['https://base.example.com']);
270+
});
271+
272+
it('an unusable value stays unusable — TLS does not manufacture an origin', () => {
273+
process.env.OS_AUTH_URL = '';
274+
const output = boot(3000, 'hotcrm', 'https');
275+
expect(mcpOrigins(output)).toEqual([]);
276+
expect(output).not.toContain('claude mcp add');
277+
expect(output).not.toContain('https://localhost:3000');
278+
});
279+
});
280+
281+
// ── #16804 ─ acceptance 2, as an ABLATION rather than a claim ──────────
282+
describe('without the flags the output is byte-for-byte what it was', () => {
283+
it('the no-flag boot equals the boot that never passes a protocol at all', () => {
284+
// Leg A: the call shape this file used before `--cert`/`--key` existed.
285+
const before = bootWithoutProtocolArg(3000, 'my-app');
286+
lines.length = 0;
287+
// Leg B: the same boot through today's call shape, no flags given.
288+
const after = boot(3000, 'my-app', 'http');
289+
290+
expect(after).toBe(before);
291+
// And the byte the two legs are about: still plain http, on the bound port.
292+
expect(mcpOrigins(after)).toEqual(['http://localhost:3000']);
293+
});
294+
295+
it('the two legs also agree on an auto-shifted port and an ephemeral one', () => {
296+
for (const port of [3001, 45064]) {
297+
lines.length = 0;
298+
const before = bootWithoutProtocolArg(port, 'my-app');
299+
lines.length = 0;
300+
expect(boot(port, 'my-app', 'http')).toBe(before);
301+
}
302+
});
303+
304+
it('and the legs DISCRIMINATE — the https leg differs from both', () => {
305+
// Without this, two legs that both silently produced nothing would pass.
306+
const plain = bootWithoutProtocolArg(3000, 'my-app');
307+
lines.length = 0;
308+
const tls = boot(3000, 'my-app', 'https');
309+
310+
expect(tls).not.toBe(plain);
311+
expect(mcpOrigins(plain)).toEqual(['http://localhost:3000']);
312+
expect(mcpOrigins(tls)).toEqual(['https://localhost:3000']);
313+
});
314+
});
315+
206316
// ── What only the source can say ────────────────────────────────────────
207317
describe('the call site feeds the printer the bound port, and nothing else', () => {
208318
it('hands `printMcpConnectHint` the ACTUALLY BOUND port', () => {
209-
expect(DEV_SOURCE).toContain('printMcpConnectHint({ boundPort: actual,');
319+
expect(DEV_SOURCE).toContain('printMcpConnectHint({');
320+
expect(DEV_SOURCE).toContain('boundPort: actual,');
321+
});
322+
323+
it('hands it the protocol it FORWARDED, not one derived a second time (#16804)', () => {
324+
// The parent's `boundProtocol` comes from the same `tlsIntent` that built
325+
// the child's argv, so the scheme the hint prints and the scheme the
326+
// child bound cannot part company. A second `resolveDevTlsIntent` call
327+
// here would be a second reader, free to disagree.
328+
expect(DEV_SOURCE).toContain('boundProtocol,');
329+
expect(DEV_SOURCE).toContain('const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent);');
330+
expect(DEV_SOURCE.match(/resolveDevTlsIntent\(/g) ?? []).toHaveLength(1);
331+
});
332+
333+
it('forwards the cert/key PATHS to the serve child through the shared contract', () => {
334+
expect(DEV_SOURCE).toContain('...devTlsChildArgs(tlsIntent),');
335+
// ⛔ and never the bytes: one reader of the file, one owner of the refusal.
336+
expect(DEV_SOURCE).not.toContain('readDevTlsMaterial');
337+
expect(DEV_SOURCE).not.toContain('readFileSync(flags.cert');
210338
});
211339

212340
it('builds no address out of the listening message any more', () => {

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
});

0 commit comments

Comments
 (0)