@dbx-tools/cli-tunnel 0.6.45 → 0.6.47

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/package.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "directory": "packages/cli/tunnel"
7
7
  },
8
8
  "bin": {
9
- "dbx-tools-tunnel": "./bin/dbx-tools-tunnel.ts",
10
- "dbxt-tunnel": "./bin/dbx-tools-tunnel.ts"
9
+ "dbx-tools-tunnel": "./lib/bin/dbx-tools-tunnel.js",
10
+ "dbxt-tunnel": "./lib/bin/dbx-tools-tunnel.js"
11
11
  },
12
12
  "scripts": {
13
13
  "build": "projen build",
@@ -29,15 +29,16 @@
29
29
  "dependencies": {
30
30
  "@clack/prompts": "^1.7.0",
31
31
  "@databricks/appkit": "^0.43.0",
32
- "@dbx-tools/appkit": "0.6.45",
33
- "@dbx-tools/email": "0.6.45",
34
- "@dbx-tools/shared-core": "0.6.45",
35
- "@dbx-tools/shared-email": "0.6.45",
32
+ "@dbx-tools/appkit": "0.6.47",
33
+ "@dbx-tools/core": "0.6.47",
34
+ "@dbx-tools/email": "0.6.47",
35
+ "@dbx-tools/shared-core": "0.6.47",
36
+ "@dbx-tools/shared-email": "0.6.47",
36
37
  "commander": "^15.0.0",
37
38
  "http-proxy-3": "^1.23.3",
38
39
  "jose": "^6.2.3"
39
40
  },
40
- "main": "index.ts",
41
+ "main": "./lib/index.js",
41
42
  "license": "UNLICENSED",
42
43
  "publishConfig": {
43
44
  "access": "public",
@@ -87,19 +88,46 @@
87
88
  "./package.json": "./package.json"
88
89
  }
89
90
  },
90
- "version": "0.6.45",
91
- "types": "index.ts",
91
+ "version": "0.6.47",
92
+ "types": "./lib/index.d.ts",
92
93
  "type": "module",
93
94
  "exports": {
94
- ".": "./index.ts",
95
- "./allowlist": "./src/allowlist.ts",
96
- "./app": "./src/app.ts",
97
- "./cli": "./src/cli.ts",
98
- "./otp": "./src/otp.ts",
99
- "./plugin": "./src/plugin.ts",
100
- "./portr": "./src/portr.ts",
101
- "./proxy": "./src/proxy.ts",
102
- "./rate-limit": "./src/rate-limit.ts",
95
+ ".": {
96
+ "types": "./lib/index.d.ts",
97
+ "default": "./lib/index.js"
98
+ },
99
+ "./allowlist": {
100
+ "types": "./lib/src/allowlist.d.ts",
101
+ "default": "./lib/src/allowlist.js"
102
+ },
103
+ "./app": {
104
+ "types": "./lib/src/app.d.ts",
105
+ "default": "./lib/src/app.js"
106
+ },
107
+ "./cli": {
108
+ "types": "./lib/src/cli.d.ts",
109
+ "default": "./lib/src/cli.js"
110
+ },
111
+ "./otp": {
112
+ "types": "./lib/src/otp.d.ts",
113
+ "default": "./lib/src/otp.js"
114
+ },
115
+ "./plugin": {
116
+ "types": "./lib/src/plugin.d.ts",
117
+ "default": "./lib/src/plugin.js"
118
+ },
119
+ "./portr": {
120
+ "types": "./lib/src/portr.d.ts",
121
+ "default": "./lib/src/portr.js"
122
+ },
123
+ "./proxy": {
124
+ "types": "./lib/src/proxy.d.ts",
125
+ "default": "./lib/src/proxy.js"
126
+ },
127
+ "./rate-limit": {
128
+ "types": "./lib/src/rate-limit.d.ts",
129
+ "default": "./lib/src/rate-limit.js"
130
+ },
103
131
  "./package.json": "./package.json"
104
132
  },
105
133
  "files": [
package/src/allowlist.ts CHANGED
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Each pattern in the configured list is one of three shapes, tried in order:
5
5
  *
6
- * - **domain shortcut** - `databricks.com` or `@databricks.com`: matches any
6
+ * - **domain shortcut** - `example.com` or `@example.com`: matches any
7
7
  * address whose domain equals it (case-insensitive). The leading `@` is
8
8
  * optional and stripped.
9
- * - **glob** - contains `*` or `?`, e.g. `*.databricks.com` or
10
- * `*@databricks.com`: matched against the WHOLE address with shell-style
9
+ * - **glob** - contains `*` or `?`, e.g. `*.example.com` or
10
+ * `*@example.com`: matched against the WHOLE address with shell-style
11
11
  * wildcards (`*` = any run, `?` = one char).
12
12
  * - **regex** - wrapped in slashes, `/.../ [flags]`: compiled and tested
13
13
  * against the whole address. An invalid regex never matches (it is skipped
package/src/app.ts CHANGED
@@ -23,15 +23,31 @@
23
23
  */
24
24
 
25
25
  import { createApp as createAppNs } from "@dbx-tools/appkit";
26
- import { email, sender, transport } from "@dbx-tools/email";
27
- import { log } from "@dbx-tools/shared-core";
26
+ import { brand as nodeBrand } from "@dbx-tools/core";
27
+ import { brand as emailBrand, email, sender, transport } from "@dbx-tools/email";
28
+ import { log, string } from "@dbx-tools/shared-core";
28
29
  import { authGate, type AuthGateApi, type AuthGateConfig } from "./plugin.ts";
29
30
 
30
31
  const logger = log.logger("tunnel:app");
31
32
 
33
+ /**
34
+ * A code TTL as the plain phrase the email states ("10 minutes", "45 seconds").
35
+ *
36
+ * Whole minutes read as minutes; anything else stays in seconds rather than
37
+ * rounding, so a 90-second TTL is not advertised as "1 minute" and a recipient is
38
+ * never told the code lives longer than it does.
39
+ */
40
+ export function expiresIn(seconds: number): string {
41
+ return seconds >= 60 && seconds % 60 === 0
42
+ ? string.pluralize(seconds / 60, "minute")
43
+ : string.pluralize(seconds, "second");
44
+ }
45
+
32
46
  const { createApp } = createAppNs;
33
47
  const { resolveSenderAddress } = sender;
34
48
  const { getEmailRuntime, sendEmail } = transport;
49
+ const { emailBrandFromContext } = emailBrand;
50
+ const { loadBrandContext } = nodeBrand;
35
51
 
36
52
  /**
37
53
  * Boot the gate app and return the API the proxy calls. Throws when email is not
@@ -40,6 +56,13 @@ const { getEmailRuntime, sendEmail } = transport;
40
56
  * fall back to insecure/open mode when the operator opted in.
41
57
  */
42
58
  export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi> {
59
+ // The host app's own brand (`branding/brand.yaml` discovered from cwd) or the
60
+ // dbx-tools default. This is the ONE brand source for the gate: it styles the
61
+ // code email (accent band, font, logo) via the email plugin AND supplies the
62
+ // display name the copy uses, so a deployment that themes its app themes its
63
+ // sign-in email with it. An explicit `brandName` still wins (see below).
64
+ const context = await loadBrandContext();
65
+
43
66
  // `sendCode` delivers the OTP through the email plugin's SHARED transport, which
44
67
  // the `email()` plugin primes during its `setup()` (awaited by `createApp`
45
68
  // below). Using the module-level `sendEmail` avoids a circular dependency on the
@@ -51,12 +74,21 @@ export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi>
51
74
  {
52
75
  to: [to],
53
76
  subject: opts.subject,
77
+ // Deliberately the conventional one-time-code layout: the prompt line,
78
+ // then the bare code ALONE on the next line, then the expiry. iOS, Gmail,
79
+ // Outlook, and Android all detect a code from this shape and offer to
80
+ // autofill it, and anything more decorative is what breaks that. The code
81
+ // stays visible TEXT in both MIME parts (the email plugin renders the
82
+ // HTML and plain-text alternatives from one React Email tree), never an
83
+ // image, so a client that scrapes the text part still finds it.
54
84
  body: [
55
85
  opts.message,
56
86
  "",
57
87
  `## ${code}`,
58
88
  "",
59
- "It expires shortly. If you didn't request this, ignore this email.",
89
+ `This code expires in ${expiresIn(opts.codeTtlSeconds)}.`,
90
+ "",
91
+ "If you did not request this code, you can ignore this email.",
60
92
  ].join("\n"),
61
93
  },
62
94
  from,
@@ -65,7 +97,20 @@ export async function startGateApp(config: AuthGateConfig): Promise<AuthGateApi>
65
97
 
66
98
  // `createApp` namespaces each plugin's exports on the handle by manifest name;
67
99
  // `handle.authGate` is the in-process gate API the proxy drives.
68
- const handle = await createApp({ plugins: [email(), authGate({ ...config, sendCode })] });
100
+ const handle = await createApp({
101
+ plugins: [
102
+ // Brand the code email from the resolved context, the same bridge every
103
+ // other dbx-tools email surface uses (accent + font inlined, logo only when
104
+ // it is a fetchable URL - a package-export path cannot load in an inbox).
105
+ email({ brand: emailBrandFromContext(context) }),
106
+ // `brandName` falls back to the resolved context's display name, so the
107
+ // email copy names the app rather than a generic placeholder. Spelled as an
108
+ // explicit `??` rather than a key before `...config`: commander sets
109
+ // `brandName` on the options object whether or not the flag was passed, so
110
+ // spreading it would clobber the context name with `undefined`.
111
+ authGate({ ...config, brandName: config.brandName ?? context.name, sendCode }),
112
+ ],
113
+ });
69
114
 
70
115
  // Fail fast: the gate needs SMTP to email codes. `getEmailRuntime()` resolves to
71
116
  // `mode: "file"` (outbox) or throws when no SMTP creds are configured - neither
package/src/cli.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * `dbx-tools-tunnel` / `dbxt-tunnel` CLI.
3
3
  *
4
- * Wraps a Databricks App start command with a public portr tunnel and an
5
- * email-OTP access gate. Everything after `--` is the REAL app start command:
4
+ * Wraps an app's start command with a public portr tunnel and an email-OTP
5
+ * access gate. Everything after `--` is the REAL app start command:
6
6
  *
7
- * dbxt-tunnel --subject "Here's your OTP" --allow databricks.com -- bun src/server.ts
7
+ * dbxt-tunnel --subject "Here's your OTP" --allow example.com -- bun src/server.ts
8
8
  *
9
9
  * Boot sequence:
10
10
  * 1. Pick a random PRIVATE port and spawn the app command with
11
- * `DATABRICKS_APP_PORT` set to it (so the app binds loopback-private).
11
+ * `DATABRICKS_APP_PORT` set to it (so the app binds loopback-private). That
12
+ * variable name is the Databricks Apps runtime contract; the gate itself is
13
+ * platform-neutral and honours whatever port it finds there.
12
14
  * 2. Boot the tiny gate AppKit app (no server): inits `CacheManager` + the
13
15
  * email transport, yields the in-process gate API.
14
16
  * 3. Start the gate PROXY on the ORIGINAL public port, forwarding to the app.
@@ -56,7 +58,7 @@ interface TunnelOpts {
56
58
  function program(): Command {
57
59
  return new Command()
58
60
  .name("dbx-tools-tunnel")
59
- .description("Front a Databricks App with a public portr tunnel + email-OTP gate")
61
+ .description("Front an app with a public portr tunnel + email-OTP gate")
60
62
  .option("--subject <text>", "Subject line for the code email (env AUTH_SUBJECT)")
61
63
  .option(
62
64
  "--allow <patterns>",
@@ -64,7 +66,10 @@ function program(): Command {
64
66
  )
65
67
  .option("--subdomain <name>", "portr subdomain (else derived from PUBLIC_DOMAIN)")
66
68
  .option("--public-domain <host>", "portr <subdomain>.<server> (env PUBLIC_DOMAIN)")
67
- .option("--brand-name <name>", "Product name in the email + login copy (env AUTH_BRAND_NAME)")
69
+ .option(
70
+ "--brand-name <name>",
71
+ "Display name in the code email copy (env AUTH_BRAND_NAME; defaults to the brand context name)",
72
+ )
68
73
  .option("--message <text>", "Line shown above the code in the email (env AUTH_MESSAGE)")
69
74
  .option("--session-ttl <seconds>", "Session lifetime (env AUTH_SESSION_TTL)")
70
75
  .option("--code-ttl <seconds>", "One-time-code lifetime (env AUTH_CODE_TTL)")
package/src/otp.ts CHANGED
@@ -9,10 +9,13 @@
9
9
  * the hash, and the entry is deleted on success or once attempts are exhausted.
10
10
  *
11
11
  * The session JWT is a short-lived HS256 token (via `jose`) carrying only the
12
- * email. Its signing key comes from `AUTH_JWT_SECRET`; when unset the gate FAILS
13
- * OPEN with an ephemeral per-process key (sessions reset on restart) rather than
14
- * refusing service - a Databricks App is already access-limited, so an unset
15
- * secret degrades to "sessions don't survive restarts", not "nobody can log in".
12
+ * email. Its signing key comes from `AUTH_JWT_SECRET`; when unset the gate uses an
13
+ * ephemeral per-process key rather than refusing service, so an unset secret
14
+ * degrades to "sessions don't survive a restart", not "nobody can log in". Note
15
+ * what this does NOT weaken: a caller still needs a code delivered to an
16
+ * allow-listed address, because the signing key only validates an ALREADY-issued
17
+ * session. Set it in any deployment running more than one instance, or sessions
18
+ * minted by one will not verify on another.
16
19
  *
17
20
  * @module
18
21
  */
package/src/plugin.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
20
- import { log, string } from "@dbx-tools/shared-core";
20
+ import { brand, env, log, string } from "@dbx-tools/shared-core";
21
21
  import type { AuthStatus } from "@dbx-tools/shared-email";
22
22
  import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
23
23
  import { CodeStore, signSession, verifySession } from "./otp.ts";
@@ -29,11 +29,30 @@ const logger = log.logger("tunnel:auth");
29
29
  export interface AuthGateConfig extends BasePluginConfig {
30
30
  /** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env EMAIL_AUTH_ALLOW. */
31
31
  allow?: string | string[];
32
- /** Subject line for the code email. Env AUTH_SUBJECT. */
32
+ /**
33
+ * Subject line for the code email. Env AUTH_SUBJECT.
34
+ *
35
+ * Defaults to "Your verification code". The wording of the subject and
36
+ * {@link message} is deliberately the conventional phrasing rather than
37
+ * anything branded: iOS, Gmail, Outlook, and Android all detect a one-time
38
+ * code from this shape and offer to autofill it, and a novel phrasing is what
39
+ * breaks that detection.
40
+ */
33
41
  subject?: string;
34
- /** Product/brand name used in the email + login copy. Env AUTH_BRAND_NAME. */
42
+ /**
43
+ * Display name used in the code email copy. Env AUTH_BRAND_NAME.
44
+ *
45
+ * Defaults to the brand context's `name` - the app's own `branding/brand.yaml`
46
+ * when it has one, else the dbx-tools default. Set this only to override the
47
+ * brand for this gate.
48
+ */
35
49
  brandName?: string;
36
- /** One-line message shown above the code in the email. Env AUTH_MESSAGE. */
50
+ /**
51
+ * Line shown immediately above the code in the email. Env AUTH_MESSAGE.
52
+ *
53
+ * Keep the code on its OWN line directly after this text - that adjacency is
54
+ * what the platform code-detection heuristics key on.
55
+ */
37
56
  message?: string;
38
57
  /** Session lifetime (seconds). Env AUTH_SESSION_TTL. Default 43200 (12h). */
39
58
  sessionTtlSeconds?: number;
@@ -50,6 +69,13 @@ export interface SendCodeOptions {
50
69
  subject: string;
51
70
  brandName: string;
52
71
  message: string;
72
+ /**
73
+ * Lifetime of the code being sent, in seconds - the RESOLVED
74
+ * {@link AuthGateConfig.codeTtlSeconds}, so the email can state the real
75
+ * expiry ("This code expires in 10 minutes") instead of a vague "shortly"
76
+ * that drifts from the configured TTL.
77
+ */
78
+ codeTtlSeconds: number;
53
79
  }
54
80
 
55
81
  /** Resolved gate config with env fallbacks + defaults applied. */
@@ -64,33 +90,39 @@ export interface ResolvedAuthGateConfig {
64
90
  }
65
91
 
66
92
  const DEFAULTS = {
67
- subject: "Your sign-in code",
68
- brandName: "This app",
69
- message: "Your one-time sign-in code is:",
93
+ subject: "Your verification code",
94
+ // The repo-wide brand context's display name, NOT a hardcoded product string:
95
+ // this name is what the recipient reads in the code email, so it has to be the
96
+ // same identity the rest of the app presents. A host with its own
97
+ // `branding/brand.yaml` overrides it by passing `brandName` (see
98
+ // {@link startGateApp}, which resolves the on-disk context); the shared default
99
+ // is the fallback when nothing is configured.
100
+ brandName: brand.defaultBrandContext.name,
101
+ message: "Your verification code is:",
70
102
  sessionTtlSeconds: 43200,
71
103
  codeTtlSeconds: 600,
72
104
  maxAttempts: 5,
73
105
  };
74
106
 
75
- /** Positive finite number from a value / env string, else the fallback. */
76
- function num(value: number | undefined, env: string | undefined, fallback: number): number {
77
- const raw = value ?? (env ? Number(env) : undefined);
78
- return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : fallback;
79
- }
80
-
81
107
  /** Merge {@link AuthGateConfig} over env over defaults into a resolved config. */
82
108
  export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
83
109
  return {
110
+ // Both sources are unioned rather than one overriding: a deployment-wide
111
+ // EMAIL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
84
112
  allow: [...string.parseList(config.allow), ...string.parseList(process.env.EMAIL_AUTH_ALLOW)],
85
- subject: config.subject ?? process.env.AUTH_SUBJECT?.trim() ?? DEFAULTS.subject,
86
- brandName: config.brandName ?? process.env.AUTH_BRAND_NAME?.trim() ?? DEFAULTS.brandName,
87
- message: config.message ?? process.env.AUTH_MESSAGE?.trim() ?? DEFAULTS.message,
88
- sessionTtlSeconds: num(
113
+ subject: env.string(config.subject, "AUTH_SUBJECT") ?? DEFAULTS.subject,
114
+ brandName: env.string(config.brandName, "AUTH_BRAND_NAME") ?? DEFAULTS.brandName,
115
+ message: env.string(config.message, "AUTH_MESSAGE") ?? DEFAULTS.message,
116
+ sessionTtlSeconds: env.positiveInt(
89
117
  config.sessionTtlSeconds,
90
- process.env.AUTH_SESSION_TTL,
118
+ "AUTH_SESSION_TTL",
91
119
  DEFAULTS.sessionTtlSeconds,
92
120
  ),
93
- codeTtlSeconds: num(config.codeTtlSeconds, process.env.AUTH_CODE_TTL, DEFAULTS.codeTtlSeconds),
121
+ codeTtlSeconds: env.positiveInt(
122
+ config.codeTtlSeconds,
123
+ "AUTH_CODE_TTL",
124
+ DEFAULTS.codeTtlSeconds,
125
+ ),
94
126
  maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
95
127
  };
96
128
  }
@@ -170,6 +202,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
170
202
  subject: this.resolved.subject,
171
203
  brandName: this.resolved.brandName,
172
204
  message: this.resolved.message,
205
+ codeTtlSeconds: this.resolved.codeTtlSeconds,
173
206
  });
174
207
  } catch (error) {
175
208
  logger.warn("failed to send OTP email", { error });
package/src/portr.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * portr install + config + launch for the tunnel CLI.
3
3
  *
4
- * On a Databricks App the container's `$HOME` is read-only on cold start, so the
5
- * portr binary and its config are placed under a writable, cwd-rooted `.home`.
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.
6
7
  * The install is idempotent (the installer skips when the on-PATH binary is
7
8
  * current). The config is rendered from `PUBLIC_DOMAIN` (`<subdomain>.<server>`)
8
9
  * + `PORTR_TOKEN` and points portr at the PUBLIC port (the proxy listens there).
package/src/proxy.ts CHANGED
@@ -7,9 +7,11 @@
7
7
  *
8
8
  * - **portr client** (same container, connects over LOOPBACK) - the public
9
9
  * tunnel. This is what the gate protects.
10
- * - **Databricks control plane / front door** (reaches the `0.0.0.0` port from
11
- * a NON-loopback container-network address) - passed through UNGATED, per the
12
- * rule "if it's not from the portr client, let it in".
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.
13
15
  *
14
16
  * The distinguisher is the connection's source address: a loopback
15
17
  * `req.socket.remoteAddress` is the portr client; anything else is the platform.
@@ -126,10 +128,11 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
126
128
  };
127
129
 
128
130
  /**
129
- * Present an OTP-authenticated caller to the app the SAME way the Databricks
130
- * front door does: set `x-forwarded-user` / `x-forwarded-email` to the verified
131
- * address (AppKit reads `x-forwarded-user` for the OBO user id). Any inbound
132
- * copies are overwritten so a client can't spoof identity through the gate.
131
+ * Present an OTP-authenticated caller to the app the SAME way a platform front
132
+ * door does: set `x-forwarded-user` / `x-forwarded-email` to the verified
133
+ * address (AppKit reads `x-forwarded-user` for the OBO user id), so the app
134
+ * needs no gate-specific code path. Any inbound copies are overwritten so a
135
+ * client can't spoof identity through the gate.
133
136
  */
134
137
  const injectIdentity = (req: IncomingMessage, email: string): void => {
135
138
  req.headers["x-forwarded-user"] = email;
@@ -139,8 +142,8 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
139
142
  const server = createServer(async (req, res) => {
140
143
  const path = (req.url ?? "/").split("?")[0]!;
141
144
 
142
- // Insecure/open mode (no gate) OR non-loopback traffic (the Databricks front
143
- // door / control plane): forward ungated.
145
+ // Insecure/open mode (no gate) OR non-loopback traffic (the hosting
146
+ // platform's front door / control plane): forward ungated.
144
147
  if (!gate || !isLoopback(req.socket.remoteAddress)) {
145
148
  proxy.web(req, res);
146
149
  return;
@@ -198,7 +201,7 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
198
201
  const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
199
202
  const email = await gate.session(token);
200
203
  if (email) {
201
- // Present the OTP user like the Databricks front door, and drop the gate
204
+ // Present the OTP user like a platform front door, and drop the gate
202
205
  // cookie so the app sees a clean, front-door-shaped request.
203
206
  injectIdentity(req, email);
204
207
  stripSessionCookie(req);