@dbx-tools/cli-tunnel 0.6.45

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/cli.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `dbx-tools-tunnel` / `dbxt-tunnel` CLI.
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:
6
+ *
7
+ * dbxt-tunnel --subject "Here's your OTP" --allow databricks.com -- bun src/server.ts
8
+ *
9
+ * Boot sequence:
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).
12
+ * 2. Boot the tiny gate AppKit app (no server): inits `CacheManager` + the
13
+ * email transport, yields the in-process gate API.
14
+ * 3. Start the gate PROXY on the ORIGINAL public port, forwarding to the app.
15
+ * 4. Install + run portr pointed at the public port (when a tunnel is
16
+ * configured; otherwise the proxy still gates nothing and forwards).
17
+ *
18
+ * Supervision: the app child, portr child, and this process are tied together -
19
+ * if ANY exits, everything comes down (concurrently-style `killOthers`).
20
+ *
21
+ * Options come from flags OR env; see the option definitions below.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { type ChildProcess, spawn } from "node:child_process";
27
+ import { log } from "@dbx-tools/shared-core";
28
+ import { Command, CommanderError } from "commander";
29
+ import { startGateApp } from "./app.ts";
30
+ import type { AuthGateConfig } from "./plugin.ts";
31
+ import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
32
+ import { startProxy } from "./proxy.ts";
33
+
34
+ export { CommanderError };
35
+
36
+ const logger = log.logger("tunnel");
37
+
38
+ /** A random ephemeral port for the app to bind (the proxy fronts the public one). */
39
+ function randomPort(): number {
40
+ return 20000 + Math.floor(Math.random() * 20000);
41
+ }
42
+
43
+ interface TunnelOpts {
44
+ subject?: string;
45
+ allow?: string;
46
+ subdomain?: string;
47
+ publicDomain?: string;
48
+ brandName?: string;
49
+ message?: string;
50
+ sessionTtl?: string;
51
+ codeTtl?: string;
52
+ insecure?: boolean;
53
+ }
54
+
55
+ /** Build the commander program. `--` separates flags from the app start command. */
56
+ function program(): Command {
57
+ return new Command()
58
+ .name("dbx-tools-tunnel")
59
+ .description("Front a Databricks App with a public portr tunnel + email-OTP gate")
60
+ .option("--subject <text>", "Subject line for the code email (env AUTH_SUBJECT)")
61
+ .option(
62
+ "--allow <patterns>",
63
+ "Comma/space-separated allow-list: domain / glob / /regex/ (env EMAIL_AUTH_ALLOW)",
64
+ )
65
+ .option("--subdomain <name>", "portr subdomain (else derived from PUBLIC_DOMAIN)")
66
+ .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)")
68
+ .option("--message <text>", "Line shown above the code in the email (env AUTH_MESSAGE)")
69
+ .option("--session-ttl <seconds>", "Session lifetime (env AUTH_SESSION_TTL)")
70
+ .option("--code-ttl <seconds>", "One-time-code lifetime (env AUTH_CODE_TTL)")
71
+ .option(
72
+ "--insecure",
73
+ "Run the tunnel OPEN with no gate (env TUNNEL_INSECURE=true). Otherwise the CLI fails fast when email SMTP is not configured.",
74
+ )
75
+ .allowExcessArguments(true)
76
+ .helpOption("-h, --help", "Show help");
77
+ }
78
+
79
+ /** Tie a child's exit to full teardown: any exit brings the whole tunnel down. */
80
+ function superviseExit(children: ChildProcess[]): void {
81
+ let shuttingDown = false;
82
+ const shutdown = (code: number): void => {
83
+ if (shuttingDown) return;
84
+ shuttingDown = true;
85
+ for (const child of children) {
86
+ if (!child.killed) child.kill("SIGTERM");
87
+ }
88
+ // Give children a moment, then exit with the first non-zero code seen.
89
+ setTimeout(() => process.exit(code), 3000).unref();
90
+ };
91
+ for (const child of children) {
92
+ child.on("exit", (code) => {
93
+ logger.warn(`child exited (${code ?? "signal"}); bringing tunnel down`);
94
+ shutdown(code ?? 1);
95
+ });
96
+ }
97
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
98
+ process.on(sig, () => shutdown(0));
99
+ }
100
+ }
101
+
102
+ /** Parse argv and run the tunnel. */
103
+ export async function runCli(argv: string[]): Promise<void> {
104
+ // Split flags from the wrapped command at the first `--`.
105
+ const sep = argv.indexOf("--");
106
+ const flags = sep >= 0 ? argv.slice(0, sep) : argv;
107
+ const command = sep >= 0 ? argv.slice(sep + 1) : [];
108
+
109
+ const prog = program();
110
+ prog.parse(flags);
111
+ const opts = prog.opts<TunnelOpts>();
112
+
113
+ if (command.length === 0) {
114
+ throw new CommanderError(1, "tunnel.no-command", "no start command given after `--`");
115
+ }
116
+
117
+ const publicPort = Number(process.env.DATABRICKS_APP_PORT ?? 8000);
118
+ const appPort = randomPort();
119
+
120
+ const gateConfig: AuthGateConfig = {
121
+ allow: opts.allow,
122
+ subject: opts.subject,
123
+ brandName: opts.brandName,
124
+ message: opts.message,
125
+ sessionTtlSeconds: opts.sessionTtl ? Number(opts.sessionTtl) : undefined,
126
+ codeTtlSeconds: opts.codeTtl ? Number(opts.codeTtl) : undefined,
127
+ };
128
+
129
+ // 1. Spawn the wrapped app with the PRIVATE port. It binds loopback; only the
130
+ // proxy reaches it.
131
+ logger.info(`spawning app on private port ${appPort}: ${command.join(" ")}`);
132
+ const [cmd, ...args] = command;
133
+ const app = spawn(cmd!, args, {
134
+ env: { ...process.env, DATABRICKS_APP_PORT: String(appPort), HOST: "127.0.0.1" },
135
+ stdio: "inherit",
136
+ });
137
+
138
+ // 2. Boot the gate app (cache + email transport + gate API). `startGateApp`
139
+ // FAILS FAST when email can't send codes (no SMTP). Insecure mode
140
+ // (`--insecure` / TUNNEL_INSECURE) skips the gate and runs the tunnel open.
141
+ const insecure = opts.insecure || /^(1|true|yes|on)$/i.test(process.env.TUNNEL_INSECURE ?? "");
142
+ let gate: Awaited<ReturnType<typeof startGateApp>> | undefined;
143
+ if (insecure) {
144
+ logger.warn("insecure mode - tunnel runs OPEN with no email-OTP gate");
145
+ } else {
146
+ try {
147
+ gate = await startGateApp(gateConfig);
148
+ } catch (error) {
149
+ // Fail fast: don't silently expose an ungated tunnel. The operator must fix
150
+ // SMTP or explicitly opt into `--insecure`.
151
+ logger.error("cannot start the OTP gate", { error: (error as Error).message });
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ // 3. Start the gate proxy on the public port (open when `gate` is undefined).
157
+ await startProxy({ publicPort, appPort, gate });
158
+
159
+ // 4. Install + run portr when a tunnel is configured.
160
+ const portrConfig = resolvePortrConfig({
161
+ publicDomain: opts.publicDomain,
162
+ subdomain: opts.subdomain,
163
+ port: publicPort,
164
+ });
165
+ const children: ChildProcess[] = [app];
166
+ if (portrConfig) {
167
+ const env = installPortr();
168
+ writePortrConfig(portrConfig, env);
169
+ children.push(startPortr(portrConfig, env));
170
+ } else {
171
+ logger.info("no PORTR_TOKEN/PUBLIC_DOMAIN - serving the gate proxy without a public tunnel");
172
+ }
173
+
174
+ // Any child exit (or a signal) tears the whole thing down.
175
+ superviseExit(children);
176
+ }
package/src/otp.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * One-time-code store + session JWT for the email-OTP tunnel gate.
3
+ *
4
+ * The code store is backed by AppKit's `CacheManager` (auto-configured to memory,
5
+ * or Lakebase when the app wires a persistent `CacheStorage`), so TTL EXPIRY and
6
+ * eviction are the cache's job - no hand-rolled Map or timers. A 6-digit code is
7
+ * generated with `crypto.randomInt` and stored as a SHA-256 hash with an attempt
8
+ * counter (never the plaintext, never in the JWT); `verify` is constant-time on
9
+ * the hash, and the entry is deleted on success or once attempts are exhausted.
10
+ *
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".
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
21
+ import { CacheManager } from "@databricks/appkit";
22
+ import { log } from "@dbx-tools/shared-core";
23
+ import { jwtVerify, SignJWT } from "jose";
24
+
25
+ const logger = log.logger("tunnel:otp");
26
+
27
+ /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
28
+ const JWT_AUD = "dbx-tools-tunnel-auth";
29
+
30
+ /** Cache-key prefix for pending codes, namespaced away from any other cache use. */
31
+ const CODE_PREFIX = "tunnel:otp:";
32
+
33
+ /** SHA-256 hex of a value. */
34
+ function sha256(value: string): string {
35
+ return createHash("sha256").update(value).digest("hex");
36
+ }
37
+
38
+ /** Constant-time compare of two equal-length hex digests. */
39
+ function safeEqualHex(a: string, b: string): boolean {
40
+ if (a.length !== b.length) return false;
41
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
42
+ }
43
+
44
+ interface CodeEntry {
45
+ hash: string;
46
+ attempts: number;
47
+ }
48
+
49
+ /** Result of {@link CodeStore.verify}. */
50
+ export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
51
+
52
+ /**
53
+ * Pending one-time codes, stored in AppKit's cache keyed by lowercased email.
54
+ * Expiry is the cache's TTL (no manual clock); a miss means expired-or-never.
55
+ */
56
+ export class CodeStore {
57
+ constructor(
58
+ private readonly ttlSeconds: number,
59
+ private readonly maxAttempts: number,
60
+ ) {}
61
+
62
+ private cache(): CacheManager {
63
+ return CacheManager.getInstanceSync();
64
+ }
65
+
66
+ private key(email: string): string {
67
+ return `${CODE_PREFIX}${email.toLowerCase()}`;
68
+ }
69
+
70
+ /**
71
+ * Generate, store (hashed, with the cache TTL), and RETURN a fresh 6-digit
72
+ * code. The caller emails the returned plaintext; only the hash is retained.
73
+ * Replaces any pending code for the address.
74
+ */
75
+ async issue(email: string): Promise<string> {
76
+ const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
77
+ const entry: CodeEntry = { hash: sha256(code), attempts: 0 };
78
+ await this.cache().set(this.key(email), entry, { ttl: this.ttlSeconds });
79
+ return code;
80
+ }
81
+
82
+ /**
83
+ * Check `code` for `email`. A cache miss is `expired` (TTL elapsed or never
84
+ * issued). Deletes the entry on success or when attempts are exhausted, so a
85
+ * code is single-use and can't be brute-forced past the cap. An attempt
86
+ * increments the stored counter (re-persisted with a fresh TTL window).
87
+ */
88
+ async verify(email: string, code: string): Promise<VerifyOutcome> {
89
+ const key = this.key(email);
90
+ const entry = await this.cache().get<CodeEntry>(key);
91
+ if (!entry) return "expired";
92
+ const attempts = entry.attempts + 1;
93
+ if (safeEqualHex(entry.hash, sha256(code))) {
94
+ await this.cache().delete(key);
95
+ return "ok";
96
+ }
97
+ if (attempts >= this.maxAttempts) {
98
+ await this.cache().delete(key);
99
+ return "too-many-attempts";
100
+ }
101
+ await this.cache().set(key, { ...entry, attempts }, { ttl: this.ttlSeconds });
102
+ return "invalid";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve the HS256 signing key. Prefers `AUTH_JWT_SECRET`; when unset, mints an
108
+ * ephemeral per-process key (fail-open) and warns once. Memoized.
109
+ */
110
+ let cachedKey: Uint8Array | undefined;
111
+ function signingKey(): Uint8Array {
112
+ if (cachedKey) return cachedKey;
113
+ const secret = process.env.AUTH_JWT_SECRET?.trim();
114
+ if (secret) {
115
+ cachedKey = new TextEncoder().encode(secret);
116
+ } else {
117
+ logger.warn(
118
+ "AUTH_JWT_SECRET is not set - using an ephemeral per-process key; sessions will not survive a restart",
119
+ );
120
+ cachedKey = randomBytes(32);
121
+ }
122
+ return cachedKey;
123
+ }
124
+
125
+ /** Reset the memoized key (tests, or after changing the env in-process). */
126
+ export function resetSigningKey(): void {
127
+ cachedKey = undefined;
128
+ }
129
+
130
+ /** Mint a short-lived session JWT for `email`, expiring in `ttlSeconds`. */
131
+ export async function signSession(email: string, ttlSeconds: number): Promise<string> {
132
+ return new SignJWT({ email })
133
+ .setProtectedHeader({ alg: "HS256" })
134
+ .setSubject(email)
135
+ .setAudience(JWT_AUD)
136
+ .setIssuedAt()
137
+ .setExpirationTime(`${ttlSeconds}s`)
138
+ .sign(signingKey());
139
+ }
140
+
141
+ /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
142
+ export async function verifySession(token: string | undefined): Promise<string | undefined> {
143
+ if (!token) return undefined;
144
+ try {
145
+ const { payload } = await jwtVerify(token, signingKey(), { audience: JWT_AUD });
146
+ return typeof payload.email === "string" ? payload.email : undefined;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,200 @@
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 { log, string } from "@dbx-tools/shared-core";
21
+ import type { AuthStatus } from "@dbx-tools/shared-email";
22
+ import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
23
+ import { CodeStore, signSession, verifySession } from "./otp.ts";
24
+ import { RateLimiter } from "./rate-limit.ts";
25
+
26
+ const logger = log.logger("tunnel:auth");
27
+
28
+ /** Options for the {@link authGate} plugin (all resolvable from env - see below). */
29
+ export interface AuthGateConfig extends BasePluginConfig {
30
+ /** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env EMAIL_AUTH_ALLOW. */
31
+ allow?: string | string[];
32
+ /** Subject line for the code email. Env AUTH_SUBJECT. */
33
+ subject?: string;
34
+ /** Product/brand name used in the email + login copy. Env AUTH_BRAND_NAME. */
35
+ brandName?: string;
36
+ /** One-line message shown above the code in the email. Env AUTH_MESSAGE. */
37
+ message?: string;
38
+ /** Session lifetime (seconds). Env AUTH_SESSION_TTL. Default 43200 (12h). */
39
+ sessionTtlSeconds?: number;
40
+ /** One-time-code lifetime (seconds). Env AUTH_CODE_TTL. Default 600 (10m). */
41
+ codeTtlSeconds?: number;
42
+ /** Max verify attempts per issued code. Default 5. */
43
+ maxAttempts?: number;
44
+ /** Deliver a code to an address. Wired by the app to the email plugin. */
45
+ sendCode?: (email: string, code: string, opts: SendCodeOptions) => Promise<void>;
46
+ }
47
+
48
+ /** Branding/messaging passed to {@link AuthGateConfig.sendCode}. */
49
+ export interface SendCodeOptions {
50
+ subject: string;
51
+ brandName: string;
52
+ message: string;
53
+ }
54
+
55
+ /** Resolved gate config with env fallbacks + defaults applied. */
56
+ export interface ResolvedAuthGateConfig {
57
+ allow: string[];
58
+ subject: string;
59
+ brandName: string;
60
+ message: string;
61
+ sessionTtlSeconds: number;
62
+ codeTtlSeconds: number;
63
+ maxAttempts: number;
64
+ }
65
+
66
+ const DEFAULTS = {
67
+ subject: "Your sign-in code",
68
+ brandName: "This app",
69
+ message: "Your one-time sign-in code is:",
70
+ sessionTtlSeconds: 43200,
71
+ codeTtlSeconds: 600,
72
+ maxAttempts: 5,
73
+ };
74
+
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
+ /** Merge {@link AuthGateConfig} over env over defaults into a resolved config. */
82
+ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
83
+ return {
84
+ 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(
89
+ config.sessionTtlSeconds,
90
+ process.env.AUTH_SESSION_TTL,
91
+ DEFAULTS.sessionTtlSeconds,
92
+ ),
93
+ codeTtlSeconds: num(config.codeTtlSeconds, process.env.AUTH_CODE_TTL, DEFAULTS.codeTtlSeconds),
94
+ maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
95
+ };
96
+ }
97
+
98
+ /** The handlers the proxy calls in-process (returned by {@link AuthGatePlugin.exports}). */
99
+ export interface AuthGateApi {
100
+ /** Handle a code request. Always resolves `{ ok: true }` (anti-enumeration). */
101
+ request(email: string, ip: string): Promise<{ ok: true; retryAfter?: number }>;
102
+ /** Handle a code verification. On success returns the session token to cookie. */
103
+ verify(
104
+ email: string,
105
+ code: string,
106
+ ip: string,
107
+ ): Promise<{ ok: boolean; token?: string; retryAfter?: number }>;
108
+ /** Resolve the authenticated email for a session token, or undefined. */
109
+ session(token: string | undefined): Promise<string | undefined>;
110
+ /** Session TTL in seconds (for the cookie Max-Age). */
111
+ readonly sessionTtlSeconds: number;
112
+ /** The gate status payload (`enabled` is always true when this plugin runs). */
113
+ status(token: string | undefined): Promise<AuthStatus>;
114
+ }
115
+
116
+ /** AppKit plugin owning the email-OTP gate's logic (no HTTP routes; proxy-driven). */
117
+ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
118
+ static manifest = {
119
+ name: "authGate",
120
+ displayName: "Auth Gate",
121
+ description: "Email one-time-password access gate for a public tunnel.",
122
+ stability: "beta",
123
+ resources: { required: [], optional: [] },
124
+ } satisfies PluginManifest<"authGate">;
125
+
126
+ private resolved!: ResolvedAuthGateConfig;
127
+ private codes!: CodeStore;
128
+ // Requesting a code is email-spam-prone; verifying is a brute-force surface.
129
+ // Per-email AND per-IP so neither axis alone is a bypass.
130
+ private readonly requestLimiter = new RateLimiter(5, 15 * 60 * 1000);
131
+ private readonly verifyLimiter = new RateLimiter(10, 15 * 60 * 1000);
132
+
133
+ override async setup(): Promise<void> {
134
+ this.resolved = resolveAuthGateConfig(this.config);
135
+ this.codes = new CodeStore(this.resolved.codeTtlSeconds, this.resolved.maxAttempts);
136
+ logger.info("ready", {
137
+ patterns: this.resolved.allow.length,
138
+ sessionTtlSeconds: this.resolved.sessionTtlSeconds,
139
+ });
140
+ }
141
+
142
+ override exports(): AuthGateApi {
143
+ return {
144
+ sessionTtlSeconds: this.resolved.sessionTtlSeconds,
145
+ request: (email, ip) => this.handleRequest(email, ip),
146
+ verify: (email, code, ip) => this.handleVerify(email, code, ip),
147
+ session: (token) => verifySession(token),
148
+ status: async (token) => ({
149
+ authenticated: Boolean(await verifySession(token)),
150
+ email: (await verifySession(token)) ?? undefined,
151
+ enabled: true,
152
+ }),
153
+ };
154
+ }
155
+
156
+ private async handleRequest(
157
+ email: string,
158
+ ip: string,
159
+ ): Promise<{ ok: true; retryAfter?: number }> {
160
+ const address = email.trim().toLowerCase();
161
+ const byIp = this.requestLimiter.hit(`ip:${ip}`);
162
+ const byEmail = this.requestLimiter.hit(`email:${address}`);
163
+ if (!byIp.allowed || !byEmail.allowed) {
164
+ return { ok: true, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
165
+ }
166
+ if (looksLikeEmail(address) && matchesAllowlist(address, this.resolved.allow)) {
167
+ const code = await this.codes.issue(address);
168
+ try {
169
+ await this.config.sendCode?.(address, code, {
170
+ subject: this.resolved.subject,
171
+ brandName: this.resolved.brandName,
172
+ message: this.resolved.message,
173
+ });
174
+ } catch (error) {
175
+ logger.warn("failed to send OTP email", { error });
176
+ }
177
+ }
178
+ return { ok: true };
179
+ }
180
+
181
+ private async handleVerify(
182
+ email: string,
183
+ code: string,
184
+ ip: string,
185
+ ): Promise<{ ok: boolean; token?: string; retryAfter?: number }> {
186
+ const address = email.trim().toLowerCase();
187
+ const byIp = this.verifyLimiter.hit(`ip:${ip}`);
188
+ const byEmail = this.verifyLimiter.hit(`email:${address}`);
189
+ if (!byIp.allowed || !byEmail.allowed) {
190
+ return { ok: false, retryAfter: byIp.retryAfter ?? byEmail.retryAfter };
191
+ }
192
+ if ((await this.codes.verify(address, code.trim())) !== "ok") return { ok: false };
193
+ this.requestLimiter.reset(`email:${address}`);
194
+ this.verifyLimiter.reset(`email:${address}`);
195
+ return { ok: true, token: await signSession(address, this.resolved.sessionTtlSeconds) };
196
+ }
197
+ }
198
+
199
+ /** Factory: `authGate({ allow, subject, ... })` for an AppKit `plugins` array. */
200
+ export const authGate = toPlugin(AuthGatePlugin);
package/src/portr.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * portr install + config + launch for the tunnel CLI.
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`.
6
+ * The install is idempotent (the installer skips when the on-PATH binary is
7
+ * current). The config is rendered from `PUBLIC_DOMAIN` (`<subdomain>.<server>`)
8
+ * + `PORTR_TOKEN` and points portr at the PUBLIC port (the proxy listens there).
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { spawn, spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { delimiter, join } from "node:path";
16
+ import { log } from "@dbx-tools/shared-core";
17
+
18
+ const logger = log.logger("tunnel:portr");
19
+
20
+ /** Resolved portr wiring, or `undefined` when no tunnel is configured. */
21
+ export interface PortrConfig {
22
+ subdomain: string;
23
+ server: string;
24
+ token: string;
25
+ port: number;
26
+ }
27
+
28
+ /**
29
+ * Resolve portr config from `PUBLIC_DOMAIN` + `PORTR_TOKEN`, or an explicit
30
+ * `subdomain`. `PUBLIC_DOMAIN` is `<subdomain>.<server>` (e.g.
31
+ * `demo.apps.dbx.tools`). Returns `undefined` (no tunnel) when the token or a
32
+ * usable domain is absent.
33
+ */
34
+ export function resolvePortrConfig(opts: {
35
+ publicDomain?: string;
36
+ subdomain?: string;
37
+ token?: string;
38
+ port: number;
39
+ }): PortrConfig | undefined {
40
+ const token = opts.token ?? process.env.PORTR_TOKEN;
41
+ const domain = opts.publicDomain ?? process.env.PUBLIC_DOMAIN;
42
+ if (!token) return undefined;
43
+ let subdomain = opts.subdomain;
44
+ let server: string | undefined;
45
+ if (domain) {
46
+ subdomain ??= domain.split(".")[0];
47
+ server = domain.slice(domain.indexOf(".") + 1);
48
+ }
49
+ server ??= process.env.PORTR_SERVER;
50
+ if (!subdomain || !server || server === domain) return undefined;
51
+ return { subdomain, server, token, port: opts.port };
52
+ }
53
+
54
+ /** The writable home portr installs + configures under (Apps `$HOME` is read-only). */
55
+ function portrHome(): string {
56
+ const home = join(process.cwd(), ".home");
57
+ mkdirSync(join(home, ".portr", "bin"), { recursive: true });
58
+ return home;
59
+ }
60
+
61
+ /** Install portr (idempotent) into the cwd-rooted home and return the child env. */
62
+ export function installPortr(): NodeJS.ProcessEnv {
63
+ const home = portrHome();
64
+ const env: NodeJS.ProcessEnv = {
65
+ ...process.env,
66
+ HOME: home,
67
+ PORTR_AUTO_ADD_PATH: "no",
68
+ PATH: [join(home, ".portr", "bin"), process.env.PATH ?? ""].join(delimiter),
69
+ };
70
+ logger.info("installing portr (idempotent)");
71
+ const res = spawnSync("bash", ["-c", "curl -sSf https://install.portr.dev | sh"], {
72
+ env,
73
+ stdio: "inherit",
74
+ });
75
+ if (res.status !== 0) throw new Error("portr install failed");
76
+ return env;
77
+ }
78
+
79
+ /** Render `~/.portr/config.yaml` for the resolved tunnel. */
80
+ export function writePortrConfig(config: PortrConfig, env: NodeJS.ProcessEnv): void {
81
+ const path = join(env.HOME!, ".portr", "config.yaml");
82
+ writeFileSync(
83
+ path,
84
+ [
85
+ `server_url: ${config.server}`,
86
+ `ssh_url: ${config.server}:4444`,
87
+ `secret_key: ${config.token}`,
88
+ "disable_dashboard: true",
89
+ "disable_tui: true",
90
+ "tunnels:",
91
+ ` - name: ${config.subdomain}`,
92
+ ` subdomain: ${config.subdomain}`,
93
+ ` port: ${config.port}`,
94
+ "",
95
+ ].join("\n"),
96
+ );
97
+ }
98
+
99
+ /** Launch `portr start` as a child process (caller supervises + kills it). */
100
+ export function startPortr(config: PortrConfig, env: NodeJS.ProcessEnv): ReturnType<typeof spawn> {
101
+ // Reclaim the subdomain from any portr left by a previous boot in this container.
102
+ spawnSync("pkill", ["-x", "portr"], { stdio: "ignore" });
103
+ logger.info(`portr tunneling https://${config.subdomain}.${config.server} -> :${config.port}`);
104
+ return spawn("portr", ["start"], { env, stdio: "inherit" });
105
+ }
106
+
107
+ /** True when the installed portr binary path exists (post-install sanity). */
108
+ export function portrInstalled(env: NodeJS.ProcessEnv): boolean {
109
+ return existsSync(join(env.HOME!, ".portr", "bin", "portr"));
110
+ }