@@ -62,6 +62,20 @@ import {
6262 MAX_PORT ,
6363 type PortInputSource ,
6464} from '../utils/port-contract.js' ;
65+ // The ONE dev-TLS contract, shared with `dev` (which spawns this command) so the
66+ // protocol the parent derives and the protocol this process actually binds come
67+ // from a single reader (#16804). ⛔ Nothing about certificates is declared in
68+ // this file, and ⛔ nothing anywhere generates one — see that module's header.
69+ import {
70+ devTlsCertFlag ,
71+ devTlsKeyFlag ,
72+ resolveDevTlsIntent ,
73+ readDevTlsMaterial ,
74+ listenerProtocol ,
75+ colorizeDevTlsNotice ,
76+ type DevTlsMaterial ,
77+ type ListenerProtocol ,
78+ } from '../utils/dev-tls-contract.js' ;
6579import { BootLogCapture , isVerboseBootLevel } from '../utils/boot-log-capture.js' ;
6680import { graftAuthoredRuntimeMembers , isAppPluginLike } from '../utils/graft-runtime-hooks.js' ;
6781// [ADR-0130 D4 / option B, #15006] Every read below that keys off a
@@ -542,8 +556,21 @@ export interface BoundPortChannels {
542556 * it can only lose the race often enough for someone to notice — which is
543557 * precisely the year-of-flakes this replaces.
544558 */
545- export function publishBoundPort ( boundPort : number , channels : BoundPortChannels ) : void {
546- const url = `http://localhost:${ boundPort } ` ;
559+ export function publishBoundPort (
560+ boundPort : number ,
561+ channels : BoundPortChannels ,
562+ boundProtocol : ListenerProtocol = 'http' ,
563+ ) : void {
564+ // ⭐ The SOCKET's own address, so the scheme has to be the one the socket
565+ // actually speaks (#16804). Both consumers of this `url` open it: the runtime
566+ // state file is what an external supervisor or health check dials, and the
567+ // IPC message is what the `os dev` parent learns the server from. Under
568+ // `--cert`/`--key` a hardcoded `http://` here would hand both of them an
569+ // address that answers a TLS handshake error — a machine-readable surface
570+ // saying something untrue about the process that wrote it. ⛔ Unlike the
571+ // canonical origin, this is NOT overridable by `OS_AUTH_URL`: that variable
572+ // names where a deployment is REACHED, and this line names what was BOUND.
573+ const url = `${ boundProtocol } ://localhost:${ boundPort } ` ;
547574 // 1 ─ THE FILE FIRST. Both announcements below send a consumer to it.
548575 channels . writeRuntimeState ( { port : boundPort , url } ) ;
549576 // 2 ─ IPC: the `os dev` parent learns the real port without polling.
@@ -947,6 +974,11 @@ export default class Serve extends Command {
947974 options : [ ...LOG_LEVELS ] ,
948975 } ) ,
949976 verbose : Flags . boolean ( { char : 'v' , description : 'Verbose output — shortcut for --log-level debug.' } ) ,
977+ // #16804 — developer-supplied TLS, declared through the shared contract so
978+ // `dev` (which spawns this command) and `serve` cannot drift on the flag
979+ // names, the prose, or what half a pair means.
980+ cert : devTlsCertFlag ( ) ,
981+ key : devTlsKeyFlag ( ) ,
950982 } ;
951983
952984 /**
@@ -1879,6 +1911,38 @@ export default class Serve extends Command {
18791911 }
18801912 const requestedPort = parsedPort ;
18811913
1914+ // ── The TLS pair, refused here for the same reason the port is (#16804) ──
1915+ // Ahead of every socket and every import that branches on the protocol, and
1916+ // BEFORE `resolveAuthBaseUrl` is consulted anywhere: the canonical origin's
1917+ // fallback tail is derived from this answer, so a pair that will not be
1918+ // honoured must never reach the resolver as if it would be.
1919+ const tlsIntent = resolveDevTlsIntent ( flags ) ;
1920+ if ( tlsIntent . kind === 'incomplete' ) {
1921+ printDiagnostic ( colorizeDevTlsNotice ( tlsIntent . notice ) ) ;
1922+ this . exit ( 1 ) ;
1923+ }
1924+ /**
1925+ * The scheme this process will actually speak — `https` only when a
1926+ * certificate and a key were BOTH given. Every origin this boot advertises
1927+ * is built from it, and there is no other way to set it.
1928+ */
1929+ const boundProtocol : ListenerProtocol = listenerProtocol ( tlsIntent ) ;
1930+ /**
1931+ * PEM bytes for the listener, read HERE so an unreadable certificate is
1932+ * refused by the process that would have bound the socket, naming the flag
1933+ * and the path — ⛔ never degraded to a plain-http listener (see
1934+ * `formatUnreadableDevTlsFileNotice`).
1935+ */
1936+ let tlsMaterial : DevTlsMaterial | undefined ;
1937+ if ( tlsIntent . kind === 'requested' ) {
1938+ try {
1939+ tlsMaterial = readDevTlsMaterial ( tlsIntent ) ;
1940+ } catch ( e : any ) {
1941+ printDiagnostic ( colorizeDevTlsNotice ( String ( e ?. message ?? e ) ) ) ;
1942+ this . exit ( 1 ) ;
1943+ }
1944+ }
1945+
18821946 // ── …and it has to BE the port the text SAYS, or say otherwise (#12674) ──
18831947 // `parseInt` is kept as the reader (#12662's ruling: nothing that boots
18841948 // today may be refused), and its tolerance changes the answer rather than
@@ -3242,7 +3306,13 @@ export default class Serve extends Command {
32423306 if ( flags . server && ! configHasHonoServer ) {
32433307 try {
32443308 const { HonoServerPlugin } = await import ( '@objectstack/plugin-hono-server' ) ;
3245- const serverPlugin = new HonoServerPlugin ( { port } ) ;
3309+ // `tls` is `undefined` unless --cert/--key were both given and both
3310+ // files read (#16804) — the adapter then binds a TLS listener with
3311+ // the same fetch handler. ⛔ Nothing here generates a certificate.
3312+ const serverPlugin = new HonoServerPlugin ( {
3313+ port,
3314+ tls : tlsMaterial ? { cert : tlsMaterial . cert , key : tlsMaterial . key } : undefined ,
3315+ } ) ;
32463316 await kernel . use ( serverPlugin ) ;
32473317 trackPlugin ( 'HonoServer' ) ;
32483318 } catch ( e : any ) {
@@ -3567,7 +3637,7 @@ export default class Serve extends Command {
35673637 // additionally reports where the value came from and whether it
35683638 // parses, so an unusable one can be said out loud instead of
35693639 // vanishing into an empty catch (#10202).
3570- const baseUrlResolution = resolveAuthBaseUrl ( port ) ;
3640+ const baseUrlResolution = resolveAuthBaseUrl ( port , boundProtocol ) ;
35713641 const baseUrl = baseUrlResolution . value ;
35723642
35733643 const socialProviders : Record < string , { clientId : string ; clientSecret : string } > = { } ;
@@ -4851,7 +4921,7 @@ export default class Serve extends Command {
48514921 // the bound one. `baseOrigin` is `null` when the chain produced
48524922 // something unparseable; the banner then prints paths with no origin
48534923 // rather than a confident wrong URL.
4854- externalBaseOrigin : resolveAuthBaseUrl ( boundPort ) . baseOrigin ,
4924+ externalBaseOrigin : resolveAuthBaseUrl ( boundPort , boundProtocol ) . baseOrigin ,
48554925 ...resolveBannerConfigRow ( { relativeConfig, useArtifactFallback, pinnedArtifact } ) ,
48564926 isDev,
48574927 pluginCount : loadedPlugins . length ,
@@ -4914,7 +4984,7 @@ export default class Serve extends Command {
49144984 // the file is written BEFORE either channel announces the address that
49154985 // sends a consumer to it. {@link publishBoundPort } carries the race the
49164986 // old order lost, and the reason the repair is not reader-side polling.
4917- publishBoundPort ( boundPort , runtimeBoundPortChannels ( printBanner ) ) ;
4987+ publishBoundPort ( boundPort , runtimeBoundPortChannels ( printBanner ) , boundProtocol ) ;
49184988
49194989 // ── Watch the served database file's identity ──────────────────
49204990 // Deleting the data directory under a running server (`rm -rf
@@ -5630,11 +5700,35 @@ export interface AuthBaseUrlResolution {
56305700 * `baseOrigin` is spelled `${protocol}//${host}` rather than `URL.origin`
56315701 * because that is what the inline code computed, and the two disagree for
56325702 * non-special schemes (`URL.origin` answers the string `"null"`).
5703+ *
5704+ * ## The tail follows the LISTENER, and only the tail (#16804)
5705+ *
5706+ * `boundProtocol` names the scheme this process actually bound — `https`
5707+ * exactly when `--cert`/`--key` were both given, derived in `dev-tls-contract`
5708+ * from that material and settable nowhere else. It reaches only the built-in
5709+ * default at the end of the chain, because that default is the one link nobody
5710+ * configured: it is this process describing its own socket, and once TLS
5711+ * terminates in-process `http://localhost:<port>` is an address no client can
5712+ * reach.
5713+ *
5714+ * ⛔ Every CONFIGURED link is untouched. `OS_AUTH_URL`, its legacy name and
5715+ * `OS_BASE_URL` answer a different question — where this deployment is
5716+ * REACHED, which behind a proxy or a tunnel has no relation to what this
5717+ * process bound — so they keep winning, https listener or not, and an
5718+ * `http://` value under a TLS listener is an operator's deliberate statement
5719+ * rather than a default to override.
5720+ *
5721+ * The parameter DEFAULTS to `'http'`, which is what every caller that never
5722+ * passes it resolved to before this existed: on a tree with no TLS flags in
5723+ * play the value, the source and the origin are byte-for-byte what they were.
56335724 */
5634- export function resolveAuthBaseUrl ( port : number | string ) : AuthBaseUrlResolution {
5725+ export function resolveAuthBaseUrl (
5726+ port : number | string ,
5727+ boundProtocol : ListenerProtocol = 'http' ,
5728+ ) : AuthBaseUrlResolution {
56355729 const value = readEnvWithDeprecation ( 'OS_AUTH_URL' , 'BETTER_AUTH_URL' , { silent : true } )
56365730 ?? process . env . OS_BASE_URL
5637- ?? `http ://localhost:${ port } ` ;
5731+ ?? `${ boundProtocol } ://localhost:${ port } ` ;
56385732
56395733 // Mirrors readEnvWithDeprecation's own precedence (preferred, then legacy),
56405734 // for REPORTING only — the value above is what actually takes effect.
0 commit comments