You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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` <path> `--key` <path>
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 <path> --key
<path>:开发者自带证书,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:<port> 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>
|`-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`|
175
177
|`--ui`| — | Force Console UI on (already on by default in dev) |
176
178
|`--compile`| — | Force compiling `objectstack.config.ts` → `dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) |
177
179
|`--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 |
178
180
|`--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`|
179
181
|`-v, --verbose`| — | Verbose output |
180
182
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
+
181
211
By default `os dev` keeps your data between restarts in a project-local SQLite
182
212
file at `.objectstack/data/dev.db` (created on first run). Pass `--database`,
183
213
set `OS_DATABASE_URL`, or use `--fresh` for a throwaway run.
|`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. |
78
78
|`OS_AUTH_SECRET`| string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
79
79
|`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. |
80
80
|`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. |
0 commit comments