@dbx-tools/tunnel 0.6.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `authGate()` - the AppKit plugin behind the tunnel's email-OTP gate.
3
+ *
4
+ * It has NO routes of its own: the tunnel PROXY (not an HTTP server) calls the
5
+ * handlers this plugin exposes via {@link AuthGatePlugin.exports}. The plugin
6
+ * owns the allow-list, the per-email/per-IP rate limiters, the CacheManager-backed
7
+ * one-time-code store, and the session JWT. `createApp` (with no `server()`) is
8
+ * used only to auto-init `CacheManager` + prime the sibling `email` transport;
9
+ * this plugin is where the gate logic lives.
10
+ *
11
+ * Options come from CLI flags OR env, with sensible defaults - see
12
+ * {@link resolveAuthGateConfig}. The one runtime dependency the plugin can't
13
+ * resolve itself is HOW to email the code, so it takes a `sendCode` callback the
14
+ * app wires to the email plugin.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
20
+ import { brand, env, log, object, string } from "@dbx-tools/shared-core";
21
+ import type { AuthStatus } from "@dbx-tools/shared-email";
22
+ import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
23
+ import {
24
+ ALLOW_ENV,
25
+ BRAND_NAME_ENV,
26
+ CODE_TTL_ENV,
27
+ MESSAGE_ENV,
28
+ SESSION_TTL_ENV,
29
+ SUBJECT_ENV,
30
+ } from "./env.ts";
31
+ import { CodeStore, signSession, verifySession } from "./otp.ts";
32
+ import { RateLimiter } from "./rate-limit.ts";
33
+ import { KEY_TTL_SECONDS, resolveSessionCutoff, signingKey } from "./signing-key.ts";
34
+
35
+ const logger = log.logger("tunnel:auth");
36
+
37
+ /** Options for the {@link authGate} plugin (all resolvable from env - see below). */
38
+ export interface AuthGateConfig extends BasePluginConfig {
39
+ /** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env TUNNEL_AUTH_ALLOW. */
40
+ allow?: string | string[];
41
+ /**
42
+ * Subject line for the code email. Env TUNNEL_AUTH_SUBJECT.
43
+ *
44
+ * Defaults to "Your verification code". The wording of the subject and
45
+ * {@link message} is deliberately the conventional phrasing rather than
46
+ * anything branded: iOS, Gmail, Outlook, and Android all detect a one-time
47
+ * code from this shape and offer to autofill it, and a novel phrasing is what
48
+ * breaks that detection.
49
+ *
50
+ * This is the subject TEMPLATE, not the literal line sent: the code is spliced
51
+ * into it (`"123456 is your verification code"`) because a push notification
52
+ * shows only the subject and preheader, and that notification is what mobile
53
+ * autofill reads. See `codeEmailSubject` in `./app.ts`.
54
+ */
55
+ subject?: string;
56
+ /**
57
+ * Display name used in the code email copy. Env TUNNEL_AUTH_BRAND_NAME.
58
+ *
59
+ * Defaults to the brand context's `name` - the app's own `branding/brand.yaml`
60
+ * when it has one, else the dbx-tools default. Set this only to override the
61
+ * brand for this gate.
62
+ */
63
+ brandName?: string;
64
+ /**
65
+ * Line shown immediately above the code in the email. Env TUNNEL_AUTH_MESSAGE.
66
+ *
67
+ * Keep the code on its OWN line directly after this text - that adjacency is
68
+ * what the platform code-detection heuristics key on.
69
+ */
70
+ message?: string;
71
+ /**
72
+ * Session lifetime (seconds). Env TUNNEL_AUTH_SESSION_TTL. Default 2592000 (30d).
73
+ *
74
+ * Matched to the cache-backed signing key's own 30-day TTL (see
75
+ * `./signing-key.ts`): the cookie and the key that validates it should expire
76
+ * together, or one silently outlives the other.
77
+ */
78
+ sessionTtlSeconds?: number;
79
+ /** One-time-code lifetime (seconds). Env TUNNEL_AUTH_CODE_TTL. Default 600 (10m). */
80
+ codeTtlSeconds?: number;
81
+ /** Max verify attempts per issued code. Default 5. */
82
+ maxAttempts?: number;
83
+ /**
84
+ * Force-clear cutoff: every session issued BEFORE it stops verifying, so moving
85
+ * it forward signs everyone out. Env TUNNEL_AUTH_SESSION_CUTOFF.
86
+ *
87
+ * Anything `object.toDate` accepts: a `Date`, `2026-08-02`, an ISO instant,
88
+ * epoch seconds/millis, or a relative duration (`-30d`, `7 days ago`). Unset
89
+ * means no cutoff.
90
+ */
91
+ sessionCutoff?: string | number | Date;
92
+ /** Deliver a code to an address. Wired by the app to the email plugin. */
93
+ sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
94
+ }
95
+
96
+ /** Branding/messaging passed to {@link AuthGateConfig.sendCode}. */
97
+ export interface SendCodeOptions {
98
+ subject: string;
99
+ brandName: string;
100
+ message: string;
101
+ /**
102
+ * Lifetime of the code being sent, in seconds - the RESOLVED
103
+ * {@link AuthGateConfig.codeTtlSeconds}, so the email can state the real
104
+ * expiry ("This code expires in 10 minutes") instead of a vague "shortly"
105
+ * that drifts from the configured TTL.
106
+ */
107
+ codeTtlSeconds: number;
108
+ }
109
+
110
+ /** Resolved gate config with env fallbacks + defaults applied. */
111
+ export interface ResolvedAuthGateConfig {
112
+ allow: string[];
113
+ subject: string;
114
+ brandName: string;
115
+ message: string;
116
+ sessionTtlSeconds: number;
117
+ codeTtlSeconds: number;
118
+ maxAttempts: number;
119
+ /** Force-clear cutoff in epoch ms; `0` when unset. */
120
+ sessionCutoffMs: number;
121
+ }
122
+
123
+ const DEFAULTS = {
124
+ subject: "Your verification code",
125
+ // The repo-wide brand context's display name, NOT a hardcoded product string:
126
+ // this name is what the recipient reads in the code email, so it has to be the
127
+ // same identity the rest of the app presents. A host with its own
128
+ // `branding/brand.yaml` overrides it by passing `brandName` (see
129
+ // {@link startGateApp}, which resolves the on-disk context); the shared default
130
+ // is the fallback when nothing is configured.
131
+ brandName: brand.defaultBrandContext.name,
132
+ message: "Your verification code is:",
133
+ // 30 days, the same window the cache-backed signing key is stored for.
134
+ sessionTtlSeconds: KEY_TTL_SECONDS,
135
+ codeTtlSeconds: 600,
136
+ maxAttempts: 5,
137
+ };
138
+
139
+ /** Merge {@link AuthGateConfig} over env over defaults into a resolved config. */
140
+ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
141
+ return {
142
+ // Both sources are unioned rather than one overriding: a deployment-wide
143
+ // TUNNEL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
144
+ // Hence `parseList` on each rather than `env.list`, which stops at the first
145
+ // source that yields anything.
146
+ allow: [...string.parseList(config.allow), ...string.parseList(env.text(ALLOW_ENV))],
147
+ subject: env.string(config.subject, SUBJECT_ENV) ?? DEFAULTS.subject,
148
+ brandName: env.string(config.brandName, BRAND_NAME_ENV) ?? DEFAULTS.brandName,
149
+ message: env.string(config.message, MESSAGE_ENV) ?? DEFAULTS.message,
150
+ sessionTtlSeconds: env.positiveInt(
151
+ config.sessionTtlSeconds,
152
+ SESSION_TTL_ENV,
153
+ DEFAULTS.sessionTtlSeconds,
154
+ ),
155
+ codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
156
+ maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
157
+ sessionCutoffMs: resolveSessionCutoff(config.sessionCutoff),
158
+ };
159
+ }
160
+
161
+ /** The handlers the proxy calls in-process (returned by {@link AuthGatePlugin.exports}). */
162
+ export interface AuthGateApi {
163
+ /** Handle a code request. Always resolves `{ ok: true }` (anti-enumeration). */
164
+ request(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }>;
165
+ /** Handle a code verification. On success returns the session token to cookie. */
166
+ verify(
167
+ email: string,
168
+ code: string,
169
+ ip: string,
170
+ ): Promise<{ ok: boolean; token?: string; retryAfter?: number }>;
171
+ /** Resolve the authenticated email for a session token, or undefined. */
172
+ session(token: string | undefined): Promise<string | undefined>;
173
+ /** Session TTL in seconds (for the cookie Max-Age). */
174
+ readonly sessionTtlSeconds: number;
175
+ /** The gate status payload (`enabled` is always true when this plugin runs). */
176
+ status(token: string | undefined): Promise<AuthStatus>;
177
+ }
178
+
179
+ /** AppKit plugin owning the email-OTP gate's logic (no HTTP routes; proxy-driven). */
180
+ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
181
+ static manifest = {
182
+ name: "authGate",
183
+ displayName: "Auth Gate",
184
+ description: "Email one-time-password access gate for a public tunnel.",
185
+ stability: "beta",
186
+ resources: { required: [], optional: [] },
187
+ } satisfies PluginManifest<"authGate">;
188
+
189
+ private resolved!: ResolvedAuthGateConfig;
190
+ private codes!: CodeStore;
191
+ // Requesting a code is email-spam-prone; verifying is a brute-force surface.
192
+ // Per-email AND per-IP so neither axis alone is a bypass.
193
+ private readonly requestLimiter = new RateLimiter(5, 15 * 60 * 1000);
194
+ private readonly verifyLimiter = new RateLimiter(10, 15 * 60 * 1000);
195
+
196
+ override async setup(): Promise<void> {
197
+ this.resolved = resolveAuthGateConfig(this.config);
198
+ this.codes = new CodeStore(this.resolved.codeTtlSeconds, this.resolved.maxAttempts);
199
+ // Resolve the signing key HERE rather than lazily on the first sign-in, so a
200
+ // cache that cannot hold it (and the resulting "sessions won't survive a
201
+ // restart" warning) shows up in the startup log, not hours later.
202
+ const { cutoffMs } = await signingKey(this.resolved.sessionCutoffMs);
203
+ logger.info("ready", {
204
+ patterns: this.resolved.allow.length,
205
+ sessionTtlSeconds: this.resolved.sessionTtlSeconds,
206
+ ...object.optional("sessionCutoff", cutoffMs > 0 ? new Date(cutoffMs).toISOString() : null),
207
+ });
208
+ }
209
+
210
+ override exports(): AuthGateApi {
211
+ return {
212
+ sessionTtlSeconds: this.resolved.sessionTtlSeconds,
213
+ request: (email, ip) => this.handleRequest(email, ip),
214
+ verify: (email, code, ip) => this.handleVerify(email, code, ip),
215
+ session: (token) => verifySession(token),
216
+ status: async (token) => ({
217
+ authenticated: Boolean(await verifySession(token)),
218
+ email: (await verifySession(token)) ?? undefined,
219
+ enabled: true,
220
+ }),
221
+ };
222
+ }
223
+
224
+ private async handleRequest(
225
+ email: string,
226
+ ip: string,
227
+ ): Promise<{ ok: true; retryAfter?: number }> {
228
+ const address = email.trim().toLowerCase();
229
+ const byIp = this.requestLimiter.hit(`ip:${ip}`);
230
+ const byEmail = this.requestLimiter.hit(`email:${address}`);
231
+ if (!byIp.allowed || !byEmail.allowed) {
232
+ return { ok: true, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
233
+ }
234
+ if (looksLikeEmail(address) && matchesAllowlist(address, this.resolved.allow)) {
235
+ const code = await this.codes.issue(address);
236
+ try {
237
+ await this.config.sendCode?.(address, code, {
238
+ subject: this.resolved.subject,
239
+ brandName: this.resolved.brandName,
240
+ message: this.resolved.message,
241
+ codeTtlSeconds: this.resolved.codeTtlSeconds,
242
+ });
243
+ } catch (error) {
244
+ logger.warn("failed to send OTP email", { error });
245
+ }
246
+ }
247
+ return { ok: true };
248
+ }
249
+
250
+ private async handleVerify(
251
+ email: string,
252
+ code: string,
253
+ ip: string,
254
+ ): Promise<{ ok: boolean; token?: string; retryAfter?: number }> {
255
+ const address = email.trim().toLowerCase();
256
+ const byIp = this.verifyLimiter.hit(`ip:${ip}`);
257
+ const byEmail = this.verifyLimiter.hit(`email:${address}`);
258
+ if (!byIp.allowed || !byEmail.allowed) {
259
+ return { ok: false, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
260
+ }
261
+ if ((await this.codes.verify(address, code.trim())) !== "ok") return { ok: false };
262
+ this.requestLimiter.reset(`email:${address}`);
263
+ this.verifyLimiter.reset(`email:${address}`);
264
+ return { ok: true, token: await signSession(address, this.resolved.sessionTtlSeconds) };
265
+ }
266
+ }
267
+
268
+ /** Factory: `authGate({ allow, subject, ... })` for an AppKit `plugins` array. */
269
+ export const authGate = toPlugin(AuthGatePlugin);
package/src/portr.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * portr install + config + launch for the tunnel CLI.
3
+ *
4
+ * Some hosts (Databricks Apps among them) mount the container's `$HOME` read-only
5
+ * on cold start, so the portr binary and its config are placed under a writable,
6
+ * cwd-rooted `.home` unconditionally - it costs nothing where `$HOME` is writable.
7
+ * The install is idempotent (the installer skips when the on-PATH binary is
8
+ * current). The config is rendered from `TUNNEL_PUBLIC_DOMAIN` (`<subdomain>.<server>`)
9
+ * + `PORTR_TOKEN` and points portr at the PUBLIC port (the proxy listens there).
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { delimiter, join } from "node:path";
17
+ import { env, log } from "@dbx-tools/shared-core";
18
+ import { PUBLIC_DOMAIN_ENV } from "./env.ts";
19
+
20
+ const logger = log.logger("tunnel:portr");
21
+
22
+ /** Resolved portr wiring, or `undefined` when no tunnel is configured. */
23
+ export interface PortrConfig {
24
+ subdomain: string;
25
+ server: string;
26
+ token: string;
27
+ port: number;
28
+ }
29
+
30
+ /**
31
+ * Resolve portr config from `TUNNEL_PUBLIC_DOMAIN` + `PORTR_TOKEN`, or an
32
+ * explicit `subdomain`. The domain is `<subdomain>.<server>` (e.g.
33
+ * `demo.apps.dbx.tools`). Returns `undefined` (no tunnel) when the token or a
34
+ * usable domain is absent.
35
+ */
36
+ export function resolvePortrConfig(opts: {
37
+ publicDomain?: string;
38
+ subdomain?: string;
39
+ token?: string;
40
+ port: number;
41
+ }): PortrConfig | undefined {
42
+ // PORTR_* is upstream portr's own namespace, so it keeps its name.
43
+ const token = env.string(opts.token, "PORTR_TOKEN");
44
+ const domain = env.string(opts.publicDomain, PUBLIC_DOMAIN_ENV);
45
+ if (!token) return undefined;
46
+ let subdomain = opts.subdomain;
47
+ let server: string | undefined;
48
+ if (domain) {
49
+ subdomain ??= domain.split(".")[0];
50
+ server = domain.slice(domain.indexOf(".") + 1);
51
+ }
52
+ server ??= env.text("PORTR_SERVER") ?? undefined;
53
+ if (!subdomain || !server || server === domain) return undefined;
54
+ return { subdomain, server, token, port: opts.port };
55
+ }
56
+
57
+ /** The writable home portr installs + configures under (Apps `$HOME` is read-only). */
58
+ function portrHome(): string {
59
+ const home = join(process.cwd(), ".home");
60
+ mkdirSync(join(home, ".portr", "bin"), { recursive: true });
61
+ return home;
62
+ }
63
+
64
+ /** Install portr (idempotent) into the cwd-rooted home and return the child env. */
65
+ export function installPortr(): NodeJS.ProcessEnv {
66
+ const home = portrHome();
67
+ const env: NodeJS.ProcessEnv = {
68
+ ...process.env,
69
+ HOME: home,
70
+ PORTR_AUTO_ADD_PATH: "no",
71
+ PATH: [join(home, ".portr", "bin"), process.env.PATH ?? ""].join(delimiter),
72
+ };
73
+ logger.info("installing portr (idempotent)");
74
+ const res = spawnSync("bash", ["-c", "curl -sSf https://install.portr.dev | sh"], {
75
+ env,
76
+ stdio: "inherit",
77
+ });
78
+ if (res.status !== 0) throw new Error("portr install failed");
79
+ return env;
80
+ }
81
+
82
+ /** Render `~/.portr/config.yaml` for the resolved tunnel. */
83
+ export function writePortrConfig(config: PortrConfig, env: NodeJS.ProcessEnv): void {
84
+ const path = join(env.HOME!, ".portr", "config.yaml");
85
+ writeFileSync(
86
+ path,
87
+ [
88
+ `server_url: ${config.server}`,
89
+ `ssh_url: ${config.server}:4444`,
90
+ `secret_key: ${config.token}`,
91
+ "disable_dashboard: true",
92
+ "disable_tui: true",
93
+ "tunnels:",
94
+ ` - name: ${config.subdomain}`,
95
+ ` subdomain: ${config.subdomain}`,
96
+ ` port: ${config.port}`,
97
+ "",
98
+ ].join("\n"),
99
+ );
100
+ }
101
+
102
+ /** Launch `portr start` as a child process (caller supervises + kills it). */
103
+ export function startPortr(config: PortrConfig, env: NodeJS.ProcessEnv): ReturnType<typeof spawn> {
104
+ // Reclaim the subdomain from any portr left by a previous boot in this container.
105
+ spawnSync("pkill", ["-x", "portr"], { stdio: "ignore" });
106
+ logger.info(`portr tunneling https://${config.subdomain}.${config.server} -> :${config.port}`);
107
+ return spawn("portr", ["start"], { env, stdio: "inherit" });
108
+ }
109
+
110
+ /** True when the installed portr binary path exists (post-install sanity). */
111
+ export function portrInstalled(env: NodeJS.ProcessEnv): boolean {
112
+ return existsSync(join(env.HOME!, ".portr", "bin", "portr"));
113
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The tunnel gate reverse-proxy.
3
+ *
4
+ * Binds the PUBLIC port (the container's `DATABRICKS_APP_PORT`) and forwards to
5
+ * the real app on a private loopback port. It is the single front door for both
6
+ * traffic paths into the container:
7
+ *
8
+ * - **portr client** (same container, connects over LOOPBACK) - the public
9
+ * tunnel. This is what the gate protects.
10
+ * - **the hosting platform's front door** (Databricks Apps' control plane, or
11
+ * any other host reaching the `0.0.0.0` port from a NON-loopback
12
+ * container-network address) - passed through UNGATED, per the rule "if it's
13
+ * not from the portr client, let it in". The platform already authenticates
14
+ * that path; the gate exists for the tunnel, which nothing else protects.
15
+ *
16
+ * The distinguisher is the connection's source address: a loopback
17
+ * `req.socket.remoteAddress` is the portr client; anything else is the platform.
18
+ *
19
+ * For portr traffic the gate is a standard SPA gate: static assets + the login
20
+ * flow (`/api/email/auth/*`, answered IN-PROCESS by this proxy via the
21
+ * {@link AuthGateApi}) are open so the browser can load the client and render the
22
+ * `<AuthGate>`; every other `/api/*` needs a valid session cookie or gets 401.
23
+ * WebSocket upgrades are gated the same way and forwarded with `http-proxy-3`.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
29
+ import type { Socket } from "node:net";
30
+ import { http, json, log, token } from "@dbx-tools/shared-core";
31
+ import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
32
+ import ProxyModule from "http-proxy-3";
33
+ import { toHeaderPolicy, type HeaderPolicy } from "./headers.ts";
34
+ import type { AuthGateApi } from "./plugin.ts";
35
+
36
+ const logger = log.logger("tunnel:proxy");
37
+
38
+ /** Route prefix the login flow lives under (open, answered in-process). */
39
+ const AUTH_PREFIX = "/api/email/auth";
40
+
41
+ /** True when a socket address is loopback (the portr client, same container). */
42
+ function isLoopback(addr: string | undefined): boolean {
43
+ if (!addr) return false;
44
+ // Normalize IPv4-mapped IPv6 (`::ffff:127.0.0.1`) and bare IPv6 loopback.
45
+ const a = addr.replace(/^::ffff:/, "");
46
+ return a === "127.0.0.1" || a.startsWith("127.") || a === "::1" || a === "localhost";
47
+ }
48
+
49
+ /** Read the whole request body as text (small JSON payloads only). */
50
+ function readBody(req: IncomingMessage): Promise<string> {
51
+ return new Promise((resolve) => {
52
+ let data = "";
53
+ req.on("data", (chunk) => (data += chunk));
54
+ req.on("end", () => resolve(data));
55
+ req.on("error", () => resolve(data));
56
+ });
57
+ }
58
+
59
+ function sendJson(res: ServerResponse, status: number, body: unknown, setCookie?: string): void {
60
+ const headers: Record<string, string> = { "content-type": "application/json" };
61
+ if (setCookie) headers["set-cookie"] = setCookie;
62
+ res.writeHead(status, headers);
63
+ res.end(JSON.stringify(body));
64
+ }
65
+
66
+ /** Options for {@link startProxy}. */
67
+ export interface ProxyOptions {
68
+ /** Public port to listen on (the container's DATABRICKS_APP_PORT). */
69
+ publicPort: number;
70
+ /** Private port the real app listens on (loopback). */
71
+ appPort: number;
72
+ /**
73
+ * The in-process gate API, or `undefined` to run OPEN (insecure mode): every
74
+ * request - including portr traffic - is forwarded ungated. Used when the
75
+ * operator passed `--insecure` / `TUNNEL_INSECURE=true`.
76
+ */
77
+ gate?: AuthGateApi;
78
+ /**
79
+ * Extra `x-` request headers tunnel traffic may forward, as literals, globs, or
80
+ * `/regex/`es - unioned with {@link DEFAULT_FORWARD_HEADERS}. Every other `x-`
81
+ * header is stripped, and {@link PROTECTED_HEADERS} is stripped regardless. See
82
+ * `./headers.ts` for why the policy is an allow-list.
83
+ */
84
+ forwardHeaders?: readonly string[];
85
+ }
86
+
87
+ /** Start the gate proxy. Resolves once it is listening. */
88
+ export function startProxy({
89
+ publicPort,
90
+ appPort,
91
+ gate,
92
+ forwardHeaders,
93
+ }: ProxyOptions): Promise<void> {
94
+ const proxy = ProxyModule.createProxyServer({
95
+ target: { host: "127.0.0.1", port: appPort },
96
+ ws: true,
97
+ xfwd: true,
98
+ });
99
+ proxy.on("error", (err: Error, _req: unknown, res: unknown) => {
100
+ logger.warn("upstream proxy error", { error: err.message });
101
+ const r = res as ServerResponse | undefined;
102
+ if (r && "writeHead" in r && !r.headersSent) {
103
+ sendJson(r, 502, { error: "upstream unavailable" });
104
+ }
105
+ });
106
+
107
+ // Compiled once: the inbound-header allow-list applied to every gated request.
108
+ const headerPolicy = toHeaderPolicy(forwardHeaders);
109
+ logger.debug("inbound header policy", { forward: headerPolicy.patterns });
110
+
111
+ /** The session cookie for a verified email, as a Set-Cookie string. */
112
+ const sessionCookie = (token: string, maxAgeSeconds: number): string =>
113
+ [
114
+ `${SESSION_COOKIE_NAME}=${token}`,
115
+ "Path=/",
116
+ "HttpOnly",
117
+ "SameSite=Lax",
118
+ `Max-Age=${maxAgeSeconds}`,
119
+ process.env.NODE_ENV === "production" ? "Secure" : "",
120
+ ]
121
+ .filter(Boolean)
122
+ .join("; ");
123
+
124
+ /**
125
+ * Client IP for rate-limiting: the portr client's forwarded XFF, else the socket.
126
+ *
127
+ * Deliberately the RIGHTMOST `x-forwarded-for` entry, not the leftmost. The
128
+ * list grows left-to-right as each hop appends, so the last entry is the one
129
+ * the nearest trusted proxy (portr) wrote and every earlier entry is a value
130
+ * the caller could have sent. Reading the leftmost lets a client vary one
131
+ * header to get a fresh rate-limit bucket per request, which defeats both the
132
+ * per-IP code-request and verify-attempt limiters. Called BEFORE
133
+ * {@link HeaderPolicy.apply} strips the header, so the honest hop value is
134
+ * still available here.
135
+ */
136
+ const clientIp = (req: IncomingMessage): string => {
137
+ const forwarded = req.headers["x-forwarded-for"];
138
+ const chain = (Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? ""))
139
+ .split(",")
140
+ .map((entry) => entry.trim())
141
+ .filter(Boolean);
142
+ return chain.at(-1) ?? req.socket.remoteAddress ?? "unknown";
143
+ };
144
+
145
+ /**
146
+ * Remove the gate's session cookie from the `Cookie` header before forwarding,
147
+ * so the app never sees `dbx_auth` (it is the proxy's concern, not the app's).
148
+ * Preserves any other cookies. Removes the header entirely when it becomes empty.
149
+ */
150
+ const stripSessionCookie = (req: IncomingMessage): void => {
151
+ const raw = req.headers.cookie;
152
+ if (!raw) return;
153
+ const kept = raw
154
+ .split(";")
155
+ .map((c) => c.trim())
156
+ .filter((c) => c && !c.startsWith(`${SESSION_COOKIE_NAME}=`));
157
+ if (kept.length) req.headers.cookie = kept.join("; ");
158
+ else delete req.headers.cookie;
159
+ };
160
+
161
+ /**
162
+ * Present an OTP-authenticated caller to the app the SAME way a platform front
163
+ * door does: set the front door's own identity headers to the verified address
164
+ * (AppKit reads {@link token.USER_ID_HEADER} for the OBO user id), so the app
165
+ * needs no gate-specific code path. The names come from
166
+ * `@dbx-tools/shared-core`'s `token` module - the same constants AppKit-side
167
+ * code reads them by - rather than being re-spelled here.
168
+ *
169
+ * What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an OTP session
170
+ * proves an email address, not possession of a Databricks credential, and
171
+ * there is no way to mint one for the caller. Its absence is exactly what
172
+ * `@dbx-tools/appkit`'s `identity` `"auto"` mode detects, so a gated request
173
+ * runs as the app's service principal instead of throwing.
174
+ */
175
+ const injectIdentity = (req: IncomingMessage, email: string): void => {
176
+ req.headers[token.USER_ID_HEADER] = email;
177
+ req.headers[token.USER_EMAIL_HEADER] = email;
178
+ };
179
+
180
+ /**
181
+ * Apply the inbound-header policy to a tunnel request: every `x-` header the
182
+ * allow-list does not name is deleted, and the platform identity/transport set
183
+ * is deleted regardless. See `./headers.ts` for the reasoning; the
184
+ * security-critical case is {@link token.ACCESS_TOKEN_HEADER}, which the gate
185
+ * never sets but an app running `identity: "auto"` treats as proof the request
186
+ * can do OBO.
187
+ *
188
+ * `xfwd: true` on the proxy re-adds `x-forwarded-for`/`-proto`/`-port`/`-host`
189
+ * afterwards from the real socket, so the app still sees those - it just sees
190
+ * the honest values rather than the caller's claim.
191
+ */
192
+ const applyHeaderPolicy = (req: IncomingMessage): void => {
193
+ const removed = headerPolicy.apply(req.headers as Record<string, unknown>);
194
+ if (removed.length) logger.debug("stripped inbound headers", { removed });
195
+ };
196
+
197
+ const server = createServer(async (req, res) => {
198
+ const path = (req.url ?? "/").split("?")[0]!;
199
+
200
+ // Insecure/open mode (no gate) OR non-loopback traffic (the hosting
201
+ // platform's front door / control plane): forward ungated.
202
+ if (!gate || !isLoopback(req.socket.remoteAddress)) {
203
+ proxy.web(req, res);
204
+ return;
205
+ }
206
+
207
+ // --- portr traffic: the gate applies ---
208
+
209
+ // The login flow is answered IN-PROCESS (the app has no server to forward to).
210
+ if (path === `${AUTH_PREFIX}/status`) {
211
+ const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
212
+ sendJson(res, 200, await gate.status(token));
213
+ return;
214
+ }
215
+ if (path === `${AUTH_PREFIX}/request` && req.method === "POST") {
216
+ const parsed = authRequestSchema.safeParse(json.parseRecord(await readBody(req)));
217
+ if (!parsed.success) return sendJson(res, 200, { ok: true }); // anti-enumeration
218
+ return sendJson(res, 200, await gate.request(parsed.data.email, clientIp(req)));
219
+ }
220
+ if (path === `${AUTH_PREFIX}/verify` && req.method === "POST") {
221
+ const parsed = authVerifySchema.safeParse(json.parseRecord(await readBody(req)));
222
+ if (!parsed.success) return sendJson(res, 200, { ok: false });
223
+ const result = await gate.verify(parsed.data.email, parsed.data.code, clientIp(req));
224
+ const cookie =
225
+ result.ok && result.token ? sessionCookie(result.token, gate.sessionTtlSeconds) : undefined;
226
+ return sendJson(
227
+ res,
228
+ 200,
229
+ { ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) },
230
+ cookie,
231
+ );
232
+ }
233
+ if (path === `${AUTH_PREFIX}/logout` && req.method === "POST") {
234
+ return sendJson(
235
+ res,
236
+ 200,
237
+ { ok: true },
238
+ `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`,
239
+ );
240
+ }
241
+
242
+ // Anti-spoof: apply the inbound-header allow-list to portr traffic - only the
243
+ // gate may assert identity, and it does so below for a verified session.
244
+ applyHeaderPolicy(req);
245
+
246
+ // Static (non-API) loads freely so the SPA + <AuthGate> can render. Strip the
247
+ // session cookie so it never leaks to the static handler.
248
+ if (!path.startsWith("/api/")) {
249
+ stripSessionCookie(req);
250
+ proxy.web(req, res);
251
+ return;
252
+ }
253
+
254
+ // Every other /api/* requires a valid session.
255
+ const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
256
+ const email = await gate.session(token);
257
+ if (email) {
258
+ // Present the OTP user like a platform front door, and drop the gate
259
+ // cookie so the app sees a clean, front-door-shaped request.
260
+ injectIdentity(req, email);
261
+ stripSessionCookie(req);
262
+ proxy.web(req, res);
263
+ } else {
264
+ sendJson(res, 401, { error: "authentication required", loginPath: AUTH_PREFIX });
265
+ }
266
+ });
267
+
268
+ // WebSocket upgrades: forward front-door traffic untouched; gate loopback
269
+ // (portr) API upgrades the same way as HTTP (strip cookie + inject identity).
270
+ server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
271
+ if (!gate || !isLoopback(req.socket.remoteAddress)) {
272
+ proxy.ws(req, socket, head);
273
+ return;
274
+ }
275
+ applyHeaderPolicy(req);
276
+ if (!(req.url ?? "").startsWith("/api/")) {
277
+ stripSessionCookie(req);
278
+ proxy.ws(req, socket, head);
279
+ return;
280
+ }
281
+ const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
282
+ void gate.session(token).then((email) => {
283
+ if (!email) {
284
+ socket.destroy();
285
+ return;
286
+ }
287
+ injectIdentity(req, email);
288
+ stripSessionCookie(req);
289
+ proxy.ws(req, socket, head);
290
+ });
291
+ });
292
+
293
+ return new Promise((resolve) => {
294
+ server.listen(publicPort, "0.0.0.0", () => {
295
+ logger.info(`gate proxy on 0.0.0.0:${publicPort} -> app 127.0.0.1:${appPort}`);
296
+ resolve();
297
+ });
298
+ });
299
+ }