Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/16804-dev-https-cert-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'@objectstack/cli': minor
'@objectstack/plugin-hono-server': minor
---

feat(cli): `objectstack dev --cert <path> --key <path>` 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:<port>`: 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.
30 changes: 30 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,42 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
| `--auth-secret <s>` | `OS_AUTH_SECRET` | Override the dev-fallback secret |
| `--environment-id <id>` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) |
| `-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. |
| `--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 |
| `--key <path>` | — | 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:<port>`: 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.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `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. |
| `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. |
Expand Down
132 changes: 130 additions & 2 deletions packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
49 changes: 46 additions & 3 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:'));
Expand All @@ -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',
}),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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']] : []),
Expand Down Expand Up @@ -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,
});
}
}
});
Expand Down
Loading
Loading