@dbx-tools/tunnel 0.6.60 → 0.6.62

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/gate.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The tunnel's in-app AUTH GATE - Express middleware + login routes the
3
+ * {@link AuthGatePlugin} registers on the app's OWN server via `this.context`.
4
+ *
5
+ * This replaces the old standalone reverse-proxy (`proxy.ts`): the app is now the
6
+ * process, so there is nothing to forward to. Instead the gate is middleware that
7
+ * either short-circuits (401, or answers the open login routes) or calls `next()`
8
+ * to let the app's real handlers run. It is the "stands in for AppKit auth" path:
9
+ * a portr caller proves an email via OTP, and on success the gate injects the
10
+ * identity headers AppKit reads, so a gated request runs like a front-door one.
11
+ *
12
+ * WHICH TRAFFIC IS GATED - the `Host` header, not the socket. portr's client
13
+ * forwards with Go's `httputil.NewSingleHostReverseProxy` and a `Director` that
14
+ * PRESERVES the original `Host`, so tunnel requests arrive with
15
+ * `Host: <subdomain>.<server>` (the public domain). A local process hitting the
16
+ * app directly sends `Host: 127.0.0.1:<port>` / `localhost`. So ONLY requests whose
17
+ * `Host` matches the configured public domain are gated; the platform front door
18
+ * and any other local client pass through untouched. There is no portr-injected
19
+ * identifying header and no TCP/source-IP signal to use instead (the client dials
20
+ * the target over plain loopback).
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { http, json, log, token } from "@dbx-tools/shared-core";
26
+ import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
27
+ import type { Request, RequestHandler, Response } from "express";
28
+ import { toHeaderPolicy } from "./headers.ts";
29
+ import type { AuthGateApi } from "./plugin.ts";
30
+
31
+ const logger = log.logger("tunnel:gate");
32
+
33
+ /** Route prefix the login flow lives under (open, answered in-process). */
34
+ export const AUTH_PREFIX = "/api/email/auth";
35
+
36
+ /** Options for {@link mountGate}. */
37
+ export interface GateOptions {
38
+ /** The in-process gate API (session/request/verify/status). */
39
+ gate: AuthGateApi;
40
+ /**
41
+ * The public `<subdomain>.<server>` that identifies portr traffic by `Host`.
42
+ * When absent, no request is ever classified as tunnel traffic and the gate is
43
+ * inert (everything passes through) - a tunnel with no public domain gates
44
+ * nothing.
45
+ */
46
+ publicDomain?: string;
47
+ /**
48
+ * Extra `x-` request headers tunnel traffic may forward (unioned with the
49
+ * built-in allow-list). Every other `x-` header is stripped from tunnel traffic.
50
+ */
51
+ forwardHeaders?: readonly string[];
52
+ }
53
+
54
+ /**
55
+ * True when the request's `Host` is the tunnel's public domain - i.e. it came in
56
+ * over portr. Case-insensitive; the optional `:port` is ignored. When no public
57
+ * domain is configured, nothing is tunnel traffic.
58
+ */
59
+ export function isTunnelHost(req: Request, publicDomain: string | undefined): boolean {
60
+ if (!publicDomain) return false;
61
+ const host = (req.headers.host ?? "").toLowerCase().split(":")[0];
62
+ return host === publicDomain.toLowerCase().split(":")[0];
63
+ }
64
+
65
+ /** The session cookie for a verified email, as a Set-Cookie string. */
66
+ function sessionCookie(value: string, maxAgeSeconds: number): string {
67
+ return [
68
+ `${SESSION_COOKIE_NAME}=${value}`,
69
+ "Path=/",
70
+ "HttpOnly",
71
+ "SameSite=Lax",
72
+ `Max-Age=${maxAgeSeconds}`,
73
+ // Real enforcement in production: the session cookie must be Secure so it is
74
+ // never sent over plaintext. Local dev (http) omits it so the cookie works.
75
+ process.env.NODE_ENV === "production" ? "Secure" : "",
76
+ ]
77
+ .filter(Boolean)
78
+ .join("; ");
79
+ }
80
+
81
+ /**
82
+ * Client IP for rate-limiting: the RIGHTMOST `x-forwarded-for` entry (the value
83
+ * the nearest trusted hop - portr - wrote), else the socket address. Reading the
84
+ * rightmost, not the leftmost, stops a caller minting a fresh rate-limit bucket by
85
+ * varying the header. Called before {@link stripHeaders}.
86
+ */
87
+ function clientIp(req: Request): string {
88
+ const forwarded = req.headers["x-forwarded-for"];
89
+ const chain = (Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? ""))
90
+ .split(",")
91
+ .map((entry) => entry.trim())
92
+ .filter(Boolean);
93
+ return chain.at(-1) ?? req.socket.remoteAddress ?? "unknown";
94
+ }
95
+
96
+ /**
97
+ * Remove the gate's session cookie from the `Cookie` header before the app's
98
+ * handlers run, so the app never sees `dbx_auth` (it is the gate's concern).
99
+ * Preserves other cookies; deletes the header when it becomes empty.
100
+ */
101
+ function stripSessionCookie(req: Request): void {
102
+ const raw = req.headers.cookie;
103
+ if (!raw) return;
104
+ const kept = raw
105
+ .split(";")
106
+ .map((c) => c.trim())
107
+ .filter((c) => c && !c.startsWith(`${SESSION_COOKIE_NAME}=`));
108
+ if (kept.length) req.headers.cookie = kept.join("; ");
109
+ else delete req.headers.cookie;
110
+ }
111
+
112
+ /**
113
+ * Present an OTP-authenticated caller to the app the SAME way a platform front
114
+ * door does: set the front-door identity headers to the verified address (AppKit
115
+ * reads {@link token.USER_ID_HEADER} for the OBO user id), so the app needs no
116
+ * gate-specific code path.
117
+ *
118
+ * What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an OTP session
119
+ * proves an email, not possession of a Databricks credential. Its absence is what
120
+ * `@dbx-tools/appkit`'s `identity: "auto"` detects, so a gated request runs as the
121
+ * app service principal instead of throwing.
122
+ */
123
+ function injectIdentity(req: Request, email: string): void {
124
+ req.headers[token.USER_ID_HEADER] = email;
125
+ req.headers[token.USER_EMAIL_HEADER] = email;
126
+ }
127
+
128
+ function sendJson(res: Response, status: number, body: unknown, setCookie?: string): void {
129
+ if (setCookie) res.setHeader("set-cookie", setCookie);
130
+ res.status(status).json(body);
131
+ }
132
+
133
+ /** Read the raw request body as text (AppKit parses JSON, but the gate routes
134
+ * are mounted before that runs for tunnel traffic, so read defensively). */
135
+ function readBody(req: Request): Promise<string> {
136
+ // AppKit's json body-parser may have already populated req.body; prefer it.
137
+ if (req.body !== undefined && req.body !== null) {
138
+ return Promise.resolve(typeof req.body === "string" ? req.body : JSON.stringify(req.body));
139
+ }
140
+ return new Promise((resolve) => {
141
+ let data = "";
142
+ req.on("data", (chunk) => (data += chunk));
143
+ req.on("end", () => resolve(data));
144
+ req.on("error", () => resolve(data));
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Register the login routes and the gating middleware on the app's Express
150
+ * instance. Called from {@link AuthGatePlugin} with the router AppKit hands
151
+ * `injectRoutes`, plus `this.context.addMiddleware` for the global gate.
152
+ *
153
+ * `addRoute`/`addMiddleware` are used (via the passed callbacks) because the login
154
+ * routes live at an ABSOLUTE path (`/api/email/auth/*`, the client's contract),
155
+ * not under the plugin's `/api/authGate` base.
156
+ */
157
+ export function mountGate(
158
+ opts: GateOptions,
159
+ addRoute: (method: "get" | "post", path: string, handler: RequestHandler) => void,
160
+ addMiddleware: (path: string, handler: RequestHandler) => void,
161
+ ): void {
162
+ const { gate, publicDomain, forwardHeaders } = opts;
163
+ const headerPolicy = toHeaderPolicy(forwardHeaders);
164
+ logger.debug("gate mounted", { publicDomain, forward: headerPolicy.patterns });
165
+
166
+ const applyHeaderPolicy = (req: Request): void => {
167
+ const removed = headerPolicy.apply(req.headers as Record<string, unknown>);
168
+ if (removed.length) logger.debug("stripped inbound headers", { removed });
169
+ };
170
+
171
+ // --- Login routes (open on tunnel traffic; answered in-process) ---
172
+
173
+ const statusHandler = (async (req, res) => {
174
+ // A local or front-door caller is never gated, so the honest answer is that
175
+ // the gate does not apply - not the session state of a cookie it would never
176
+ // check. Without this, a browser on `localhost` renders the OTP login screen
177
+ // for a request that would have passed through untouched.
178
+ if (!isTunnelHost(req, publicDomain)) {
179
+ sendJson(res, 200, { authenticated: false, enabled: false });
180
+ return;
181
+ }
182
+ const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
183
+ sendJson(res, 200, await gate.status(cookie));
184
+ }) as RequestHandler;
185
+
186
+ const requestHandler = (async (req, res) => {
187
+ const parsed = authRequestSchema.safeParse(json.parseRecord(await readBody(req)));
188
+ if (!parsed.success) return sendJson(res, 200, { ok: true }); // anti-enumeration
189
+ sendJson(res, 200, await gate.request(parsed.data.email, clientIp(req)));
190
+ }) as RequestHandler;
191
+
192
+ const verifyHandler = (async (req, res) => {
193
+ const parsed = authVerifySchema.safeParse(json.parseRecord(await readBody(req)));
194
+ if (!parsed.success) return sendJson(res, 200, { ok: false });
195
+ const result = await gate.verify(parsed.data.email, parsed.data.code, clientIp(req));
196
+ const cookie =
197
+ result.ok && result.token ? sessionCookie(result.token, gate.sessionTtlSeconds) : undefined;
198
+ sendJson(
199
+ res,
200
+ 200,
201
+ { ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) },
202
+ cookie,
203
+ );
204
+ }) as RequestHandler;
205
+
206
+ const logoutHandler = ((_req, res) => {
207
+ sendJson(res, 200, { ok: true }, `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
208
+ }) as RequestHandler;
209
+
210
+ addRoute("get", `${AUTH_PREFIX}/status`, statusHandler);
211
+ addRoute("post", `${AUTH_PREFIX}/request`, requestHandler);
212
+ addRoute("post", `${AUTH_PREFIX}/verify`, verifyHandler);
213
+ addRoute("post", `${AUTH_PREFIX}/logout`, logoutHandler);
214
+
215
+ // --- The gate middleware (runs before static + the app's /api handlers) ---
216
+
217
+ const gateMiddleware = (async (req, res, next) => {
218
+ // Not tunnel traffic (platform front door, or any other local caller): the
219
+ // request is already authenticated (or is not ours to gate). Pass through.
220
+ if (!isTunnelHost(req, publicDomain)) return next();
221
+
222
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
223
+
224
+ // The login flow is open so the browser can render <AuthGate> and sign in.
225
+ if (path.startsWith(AUTH_PREFIX)) return next();
226
+
227
+ // Anti-spoof: only the gate may assert identity on tunnel traffic.
228
+ applyHeaderPolicy(req);
229
+
230
+ // Static (non-API) loads freely so the SPA can render; drop the gate cookie.
231
+ if (!path.startsWith("/api/")) {
232
+ stripSessionCookie(req);
233
+ return next();
234
+ }
235
+
236
+ // Every other /api/* needs a valid OTP session.
237
+ const cookie = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
238
+ const email = await gate.session(cookie);
239
+ if (!email) {
240
+ sendJson(res, 401, { error: "authentication required", loginPath: AUTH_PREFIX });
241
+ return;
242
+ }
243
+ injectIdentity(req, email);
244
+ stripSessionCookie(req);
245
+ next();
246
+ }) as RequestHandler;
247
+
248
+ addMiddleware("/", gateMiddleware);
249
+ }
@@ -16,16 +16,16 @@
16
16
  * 4. registers an AppKit `shutdown` lifecycle handler so an orderly app shutdown
17
17
  * also stops portr.
18
18
  *
19
- * The email-OTP GATE is a separate concern: it is the {@link authGate} AppKit
20
- * plugin plus the {@link startProxy} reverse-proxy, both exported from this package
21
- * for an app that wants to gate the tunnelled traffic. This interceptor is only the
22
- * portr half - "update the host, bind portr" - matching the smallest useful unit.
19
+ * The email-OTP GATE is a separate concern: it is the `authGate` AppKit plugin,
20
+ * which registers the login routes + a gating middleware on the app's own server.
21
+ * Register it in the app's `plugins` for gated traffic. This interceptor is only
22
+ * the portr half - "update the host, bind portr" - the smallest useful unit.
23
23
  *
24
24
  * @module
25
25
  */
26
26
 
27
27
  import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
28
- import { log } from "@dbx-tools/shared-core";
28
+ import { log, object } from "@dbx-tools/shared-core";
29
29
  import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
30
30
 
31
31
  const logger = log.logger("tunnel:interceptor");
@@ -46,7 +46,7 @@ export interface TunnelInterceptorOptions {
46
46
 
47
47
  /** Resolve the public port portr should target: explicit, else the Apps contract. */
48
48
  function resolvePublicPort(port?: number): number {
49
- return port ?? Number(process.env.DATABRICKS_APP_PORT ?? 8000);
49
+ return port ?? object.toNumber(process.env.DATABRICKS_APP_PORT) ?? 8000;
50
50
  }
51
51
 
52
52
  /**
package/src/plugin.ts CHANGED
@@ -19,21 +19,68 @@
19
19
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
20
20
  import { brand, env, log, object, string } from "@dbx-tools/shared-core";
21
21
  import type { AuthStatus } from "@dbx-tools/shared-email";
22
+ import type { RequestHandler } from "express";
22
23
  import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
23
24
  import {
24
25
  ALLOW_ENV,
25
26
  BRAND_NAME_ENV,
26
27
  CODE_TTL_ENV,
28
+ FORWARD_HEADERS_ENV,
29
+ INSECURE_ENV,
27
30
  MESSAGE_ENV,
31
+ PUBLIC_DOMAIN_ENV,
28
32
  SESSION_TTL_ENV,
29
33
  SUBJECT_ENV,
30
34
  } from "./env.ts";
35
+ import { mountGate, type GateOptions } from "./gate.ts";
31
36
  import { CodeStore, signSession, verifySession } from "./otp.ts";
32
37
  import { RateLimiter } from "./rate-limit.ts";
38
+ import { ensureEmailAvailable, sendCode as defaultSendCode } from "./send-code.ts";
33
39
  import { KEY_TTL_SECONDS, resolveSessionCutoff, signingKey } from "./signing-key.ts";
34
40
 
35
41
  const logger = log.logger("tunnel:auth");
36
42
 
43
+ interface GateServerApplication {
44
+ get(path: string, handler: RequestHandler): unknown;
45
+ post(path: string, handler: RequestHandler): unknown;
46
+ use(path: string, handler: RequestHandler): unknown;
47
+ }
48
+
49
+ interface GateMountContext {
50
+ addRoute(method: string, path: string, handler: RequestHandler): void;
51
+ addMiddleware(path: string, handler: RequestHandler): void;
52
+ getPlugins(): ReadonlyMap<string, unknown>;
53
+ }
54
+
55
+ function serverApplication(context: GateMountContext): GateServerApplication | undefined {
56
+ const server = context.getPlugins().get("server") as
57
+ { serverApplication?: Partial<GateServerApplication> } | undefined;
58
+ const application = server?.serverApplication;
59
+ return application &&
60
+ typeof application.get === "function" &&
61
+ typeof application.post === "function" &&
62
+ typeof application.use === "function"
63
+ ? (application as GateServerApplication)
64
+ : undefined;
65
+ }
66
+
67
+ export function mountGateOnContext(context: GateMountContext, options: GateOptions): void {
68
+ const application = serverApplication(context);
69
+ if (application) {
70
+ mountGate(
71
+ options,
72
+ (method, path, handler) => application[method](path, handler),
73
+ (path, handler) => application.use(path, handler),
74
+ );
75
+ return;
76
+ }
77
+ mountGate(
78
+ options,
79
+ (method, path, handler) => context.addRoute(method, path, handler),
80
+ (path, handler) => context.addMiddleware(path, handler),
81
+ );
82
+ }
83
+
37
84
  /** Options for the {@link authGate} plugin (all resolvable from env - see below). */
38
85
  export interface AuthGateConfig extends BasePluginConfig {
39
86
  /** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env TUNNEL_AUTH_ALLOW. */
@@ -50,7 +97,7 @@ export interface AuthGateConfig extends BasePluginConfig {
50
97
  * This is the subject TEMPLATE, not the literal line sent: the code is spliced
51
98
  * into it (`"123456 is your verification code"`) because a push notification
52
99
  * shows only the subject and preheader, and that notification is what mobile
53
- * autofill reads. See `codeEmailSubject` in `./app.ts`.
100
+ * autofill reads. See `codeEmailSubject` in `./code-email.ts`.
54
101
  */
55
102
  subject?: string;
56
103
  /**
@@ -89,8 +136,31 @@ export interface AuthGateConfig extends BasePluginConfig {
89
136
  * means no cutoff.
90
137
  */
91
138
  sessionCutoff?: string | number | Date;
92
- /** Deliver a code to an address. Wired by the app to the email plugin. */
139
+ /**
140
+ * Deliver a code to an address. Defaults to sending through the host app's
141
+ * shared `@dbx-tools/email` transport (see `./send-code`); override to wire a
142
+ * different delivery path.
143
+ */
93
144
  sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
145
+ /**
146
+ * The public `<subdomain>.<server>` that identifies portr traffic by its `Host`
147
+ * header. Only requests whose `Host` matches this are gated; everything else
148
+ * (the platform front door, other local callers) passes through. Env
149
+ * `TUNNEL_PUBLIC_DOMAIN`. When absent, the gate is inert (nothing is tunnel
150
+ * traffic).
151
+ */
152
+ publicDomain?: string;
153
+ /**
154
+ * Extra `x-` request headers tunnel traffic may forward (literal / glob /
155
+ * `/regex/`), unioned with the built-in allow-list. Env `TUNNEL_FORWARD_HEADERS`.
156
+ */
157
+ forwardHeaders?: string | string[];
158
+ /**
159
+ * Run OPEN with no gate (env `TUNNEL_INSECURE=true`). The login routes and gate
160
+ * middleware are not mounted, and the SMTP fail-fast is skipped. Use only when
161
+ * the tunnel is deliberately public.
162
+ */
163
+ insecure?: boolean;
94
164
  }
95
165
 
96
166
  /** Branding/messaging passed to {@link AuthGateConfig.sendCode}. */
@@ -118,6 +188,12 @@ export interface ResolvedAuthGateConfig {
118
188
  maxAttempts: number;
119
189
  /** Force-clear cutoff in epoch ms; `0` when unset. */
120
190
  sessionCutoffMs: number;
191
+ /** Public domain that identifies portr traffic by `Host`; `undefined` = inert. */
192
+ publicDomain?: string;
193
+ /** Extra `x-` headers tunnel traffic may forward (unioned with the defaults). */
194
+ forwardHeaders: string[];
195
+ /** Run OPEN with no gate. */
196
+ insecure: boolean;
121
197
  }
122
198
 
123
199
  const DEFAULTS = {
@@ -125,8 +201,7 @@ const DEFAULTS = {
125
201
  // The repo-wide brand context's display name, NOT a hardcoded product string:
126
202
  // this name is what the recipient reads in the code email, so it has to be the
127
203
  // 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
204
+ // `branding/brand.yaml` overrides it by passing `brandName`; the shared default
130
205
  // is the fallback when nothing is configured.
131
206
  brandName: brand.defaultBrandContext.name,
132
207
  message: "Your verification code is:",
@@ -155,10 +230,16 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
155
230
  codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
156
231
  maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
157
232
  sessionCutoffMs: resolveSessionCutoff(config.sessionCutoff),
233
+ publicDomain: env.string(config.publicDomain, PUBLIC_DOMAIN_ENV) ?? undefined,
234
+ forwardHeaders: [
235
+ ...string.parseList(config.forwardHeaders),
236
+ ...string.parseList(env.text(FORWARD_HEADERS_ENV)),
237
+ ],
238
+ insecure: env.boolean(config.insecure, INSECURE_ENV) ?? false,
158
239
  };
159
240
  }
160
241
 
161
- /** The handlers the proxy calls in-process (returned by {@link AuthGatePlugin.exports}). */
242
+ /** The handlers the gate middleware calls in-process (returned by {@link AuthGatePlugin.exports}). */
162
243
  export interface AuthGateApi {
163
244
  /** Handle a code request. Always resolves `{ ok: true }` (anti-enumeration). */
164
245
  request(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }>;
@@ -176,7 +257,13 @@ export interface AuthGateApi {
176
257
  status(token: string | undefined): Promise<AuthStatus>;
177
258
  }
178
259
 
179
- /** AppKit plugin owning the email-OTP gate's logic (no HTTP routes; proxy-driven). */
260
+ /**
261
+ * AppKit plugin owning the email-OTP gate. On `setup()` it registers the login
262
+ * routes (`/api/email/auth/*`) and a gating middleware on the app's OWN Express
263
+ * server via `this.context`, so a public portr caller must prove an email before
264
+ * reaching the app's `/api/*` - see `./gate`. Front-door (platform) traffic and
265
+ * other local callers pass through untouched (the gate keys on the `Host` header).
266
+ */
180
267
  export class AuthGatePlugin extends Plugin<AuthGateConfig> {
181
268
  static manifest = {
182
269
  name: "authGate",
@@ -200,13 +287,56 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
200
287
  // cache that cannot hold it (and the resulting "sessions won't survive a
201
288
  // restart" warning) shows up in the startup log, not hours later.
202
289
  const { cutoffMs } = await signingKey(this.resolved.sessionCutoffMs);
290
+
291
+ if (this.resolved.insecure) {
292
+ logger.warn("insecure mode - the tunnel runs OPEN with no email-OTP gate");
293
+ } else {
294
+ // `server()` is deferred, so it does not exist during this plugin's setup.
295
+ // At `setup:complete` the Express app exists but has not injected plugin
296
+ // routes or static handling yet, which is the one point the gate can mount
297
+ // ahead of every protected route.
298
+ this.context?.onLifecycle("setup:complete", async () => {
299
+ this.mountGateRoutes();
300
+ // Fail fast once the sibling email plugin has primed its transport: a
301
+ // gate that cannot email a code lets nobody in.
302
+ await ensureEmailAvailable();
303
+ });
304
+ }
305
+
203
306
  logger.info("ready", {
204
307
  patterns: this.resolved.allow.length,
205
308
  sessionTtlSeconds: this.resolved.sessionTtlSeconds,
309
+ publicDomain: this.resolved.publicDomain ?? null,
310
+ insecure: this.resolved.insecure,
206
311
  ...object.optional("sessionCutoff", cutoffMs > 0 ? new Date(cutoffMs).toISOString() : null),
207
312
  });
208
313
  }
209
314
 
315
+ /**
316
+ * Register the login routes (`/api/email/auth/*`) and the gating middleware on
317
+ * the app's OWN Express server. At `setup:complete`, the deferred server plugin
318
+ * has constructed its Express app but has not injected plugin routes or static
319
+ * handling, so direct registration puts the gate first. The context's buffered
320
+ * route API remains a fallback for compatible non-standard server plugins.
321
+ */
322
+ private mountGateRoutes(): void {
323
+ const context = this.context;
324
+ if (!context) {
325
+ logger.warn("no plugin context - the OTP gate cannot mount its routes");
326
+ return;
327
+ }
328
+ mountGateOnContext(context, {
329
+ gate: this.exports(),
330
+ publicDomain: this.resolved.publicDomain,
331
+ forwardHeaders: this.resolved.forwardHeaders,
332
+ });
333
+ }
334
+
335
+ /** The code sender: the configured `sendCode`, else the shared-transport default. */
336
+ private get sendCode(): NonNullable<AuthGateConfig["sendCode"]> {
337
+ return this.config.sendCode ?? defaultSendCode;
338
+ }
339
+
210
340
  override exports(): AuthGateApi {
211
341
  return {
212
342
  sessionTtlSeconds: this.resolved.sessionTtlSeconds,
@@ -234,7 +364,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
234
364
  if (looksLikeEmail(address) && matchesAllowlist(address, this.resolved.allow)) {
235
365
  const code = await this.codes.issue(address);
236
366
  try {
237
- await this.config.sendCode?.(address, code, {
367
+ await this.sendCode(address, code, {
238
368
  subject: this.resolved.subject,
239
369
  brandName: this.resolved.brandName,
240
370
  message: this.resolved.message,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The default `sendCode` for the {@link AuthGatePlugin}: deliver the OTP through
3
+ * the host app's ALREADY-PRIMED `@dbx-tools/email` transport, as the system
4
+ * sender (a verification code is machine-generated and unanswerable, so it must
5
+ * not arrive from a person's address inviting a reply).
6
+ *
7
+ * `@dbx-tools/email` is an OPTIONAL dependency of the tunnel - a tunnel used
8
+ * without the gate needs no mail. So it is imported LAZILY here; a missing module
9
+ * surfaces only when a gate actually tries to send a code, and
10
+ * {@link ensureEmailAvailable} turns that into a clear fail-fast at boot.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { log } from "@dbx-tools/shared-core";
16
+ import {
17
+ codeEmailHtmlBody,
18
+ codeEmailPreview,
19
+ codeEmailSubject,
20
+ codeEmailTextBody,
21
+ } from "./code-email.ts";
22
+ import type { AuthGateConfig, SendCodeOptions } from "./plugin.ts";
23
+
24
+ const logger = log.logger("tunnel:send-code");
25
+
26
+ /** The slice of `@dbx-tools/email` the gate uses, resolved lazily. */
27
+ interface EmailModule {
28
+ sender: { resolveSystemSenderAddress: (config: unknown) => string };
29
+ transport: {
30
+ getEmailRuntime: () => { config: { mode: string } };
31
+ sendEmail: (
32
+ message: { to: string[]; subject: string; body: string },
33
+ from: string,
34
+ onBehalfOf: undefined,
35
+ extra: { text: string; preview: string },
36
+ ) => Promise<void>;
37
+ };
38
+ }
39
+
40
+ let cached: EmailModule | undefined;
41
+
42
+ /**
43
+ * Import `@dbx-tools/email` lazily. Throws a clear, actionable error when the
44
+ * optional dependency is absent - the caller (a gate that is NOT insecure) treats
45
+ * that as fatal, since a gate that cannot email a code lets nobody in.
46
+ */
47
+ async function loadEmail(): Promise<EmailModule> {
48
+ if (cached) return cached;
49
+ try {
50
+ const mod = (await import("@dbx-tools/email")) as unknown as EmailModule;
51
+ cached = mod;
52
+ return mod;
53
+ } catch (cause) {
54
+ throw new Error(
55
+ "the OTP gate needs @dbx-tools/email to send codes, but it is not installed. " +
56
+ "Add @dbx-tools/email to the app, or run the tunnel with --insecure / TUNNEL_INSECURE=true.",
57
+ { cause },
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Fail fast when the gate cannot deliver codes: the optional email module must be
64
+ * importable AND configured for SMTP (real delivery). `mode: "file"` (outbox) or a
65
+ * throwing runtime means no code reaches a real inbox, so a gate on that config
66
+ * would silently admit nobody. Called from the plugin's `setup:complete` unless
67
+ * insecure.
68
+ */
69
+ export async function ensureEmailAvailable(): Promise<void> {
70
+ const { transport } = await loadEmail();
71
+ if (transport.getEmailRuntime().config.mode !== "smtp") {
72
+ throw new Error(
73
+ "email is not configured for SMTP delivery - the OTP gate cannot send codes. " +
74
+ "Set SMTP_HOST/SMTP_USER/SMTP_PASSWORD, or pass --insecure / TUNNEL_INSECURE=true.",
75
+ );
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The default code sender: render the OTP email (code in the subject + preheader
81
+ * for mobile autofill; see `codeEmailSubject`) and send it through the shared
82
+ * transport as the system sender.
83
+ */
84
+ export const sendCode: NonNullable<AuthGateConfig["sendCode"]> = async (
85
+ to: string,
86
+ code: string,
87
+ opts: SendCodeOptions,
88
+ ): Promise<void> => {
89
+ const { sender, transport } = await loadEmail();
90
+ const from = sender.resolveSystemSenderAddress(transport.getEmailRuntime().config);
91
+ await sendEmailWith(transport, to, code, opts, from);
92
+ };
93
+
94
+ function sendEmailWith(
95
+ transport: EmailModule["transport"],
96
+ to: string,
97
+ code: string,
98
+ opts: SendCodeOptions,
99
+ from: string,
100
+ ): Promise<void> {
101
+ logger.debug("sending code email", { to });
102
+ return transport.sendEmail(
103
+ {
104
+ to: [to],
105
+ subject: codeEmailSubject(code, opts.subject),
106
+ body: codeEmailHtmlBody(code, opts),
107
+ },
108
+ from,
109
+ undefined,
110
+ { text: codeEmailTextBody(code, opts), preview: codeEmailPreview(code, opts) },
111
+ );
112
+ }