@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/env.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Environment-variable names the tunnel reads.
3
+ *
4
+ * Every setting is `TUNNEL_`-prefixed, matching the repo convention of naming a
5
+ * variable after the package that owns it (`MASTRA_*`, `TEAMS_*`,
6
+ * `WEB_SEARCH_*`). The gate's original names were unprefixed (`AUTH_SUBJECT`,
7
+ * `PUBLIC_DOMAIN`, ...), which is a real hazard for a package that runs as a
8
+ * WRAPPER: the tunnel and the app it wraps share one environment, so a generic
9
+ * name is one the wrapped app may already use for something else, and
10
+ * `PUBLIC_DOMAIN` in particular reads like an app-wide setting rather than a
11
+ * portr detail. `EMAIL_AUTH_ALLOW` was worse than generic - it sat in
12
+ * `@dbx-tools/email`'s `EMAIL_*` namespace while configuring the gate, not email.
13
+ *
14
+ * Each entry is an {@link EnvKey} list, EARLIEST-WINS, whose first element is the
15
+ * current name and whose remaining elements are the deprecated originals. A
16
+ * deployment set up against the old names keeps working; nothing needs a
17
+ * coordinated rename. Read them through `env.string` / `env.positiveInt` /
18
+ * `env.list`, which accept the list directly.
19
+ *
20
+ * Not renamed:
21
+ *
22
+ * - `DATABRICKS_APP_PORT` - the Databricks Apps runtime contract. The platform
23
+ * sets it; the gate honours it.
24
+ * - `PORTR_TOKEN` / `PORTR_SERVER` / `PORTR_AUTO_ADD_PATH` - upstream
25
+ * [portr](https://github.com/amalshaji/portr)'s own namespace, and
26
+ * `PORTR_AUTO_ADD_PATH` is passed straight to that binary. Renaming these
27
+ * would rename someone else's contract.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import type { EnvKey } from "@dbx-tools/shared-core";
33
+
34
+ /** Access allow-list patterns (domain / glob / `/regex/`). */
35
+ export const ALLOW_ENV: EnvKey = ["TUNNEL_AUTH_ALLOW", "EMAIL_AUTH_ALLOW"];
36
+
37
+ /** Subject line for the code email. */
38
+ export const SUBJECT_ENV: EnvKey = ["TUNNEL_AUTH_SUBJECT", "AUTH_SUBJECT"];
39
+
40
+ /** Display name used in the code email copy. */
41
+ export const BRAND_NAME_ENV: EnvKey = ["TUNNEL_AUTH_BRAND_NAME", "AUTH_BRAND_NAME"];
42
+
43
+ /** Line shown immediately above the code in the email. */
44
+ export const MESSAGE_ENV: EnvKey = ["TUNNEL_AUTH_MESSAGE", "AUTH_MESSAGE"];
45
+
46
+ /** Session lifetime, in seconds. */
47
+ export const SESSION_TTL_ENV: EnvKey = ["TUNNEL_AUTH_SESSION_TTL", "AUTH_SESSION_TTL"];
48
+
49
+ /** One-time-code lifetime, in seconds. */
50
+ export const CODE_TTL_ENV: EnvKey = ["TUNNEL_AUTH_CODE_TTL", "AUTH_CODE_TTL"];
51
+
52
+ /** HS256 signing secret for the session JWT. */
53
+ export const JWT_SECRET_ENV: EnvKey = ["TUNNEL_AUTH_JWT_SECRET", "AUTH_JWT_SECRET"];
54
+
55
+ /**
56
+ * Force-clear cutoff: every session issued BEFORE it stops verifying. Anything
57
+ * `object.toDate` accepts - a date, an ISO instant, epoch seconds/millis, or a
58
+ * relative duration (`-30d`, `7 days ago`).
59
+ */
60
+ export const SESSION_CUTOFF_ENV: EnvKey = [
61
+ "TUNNEL_AUTH_SESSION_CUTOFF",
62
+ "TUNNEL_AUTH_SESSION_EPOCH",
63
+ ];
64
+
65
+ /** The public `<subdomain>.<server>` portr should serve on. */
66
+ export const PUBLIC_DOMAIN_ENV: EnvKey = ["TUNNEL_PUBLIC_DOMAIN", "PUBLIC_DOMAIN"];
67
+
68
+ /** Run the tunnel OPEN, with no gate. Ignored unless truthy. */
69
+ export const INSECURE_ENV: EnvKey = "TUNNEL_INSECURE";
70
+
71
+ /** Extra `x-` request headers tunnel traffic may forward. */
72
+ export const FORWARD_HEADERS_ENV: EnvKey = "TUNNEL_FORWARD_HEADERS";
package/src/headers.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Inbound header policy for tunnel traffic.
3
+ *
4
+ * Everything the gate forwards arrives from the PUBLIC internet through the
5
+ * portr client, so every header on it is attacker-controlled. The headers an app
6
+ * trusts are precisely the ones a caller must not be able to write, because the
7
+ * app cannot tell a header the Databricks front door set from one a browser
8
+ * typed.
9
+ *
10
+ * ## Policy shape: strip by default, allow by pattern
11
+ *
12
+ * Enumerating what to remove is a losing game - a deny-list is only correct until
13
+ * the platform adds a header or a library starts trusting another one - so the
14
+ * policy is inverted. EVERY `x-`-prefixed request header is dropped from tunnel
15
+ * traffic unless it matches a configured pattern. That fails CLOSED: a header
16
+ * nobody thought about is removed rather than trusted.
17
+ *
18
+ * The allow-list is zero-to-many literals, globs, or `/regex/`es, compiled by
19
+ * shared-core's {@link pattern.toPatternMatcher}, and is UNIONED with
20
+ * {@link DEFAULT_FORWARD_HEADERS} so extending it never silently breaks the
21
+ * built-in surfaces. Configure it with `forwardHeaders` /
22
+ * `TUNNEL_FORWARD_HEADERS`.
23
+ *
24
+ * Non-`x-` headers are untouched. Standard ones (`content-type`, `accept`,
25
+ * `authorization`, `cookie`, ...) are the app's normal input and the gate has no
26
+ * business rewriting them.
27
+ *
28
+ * ## The headers no pattern can forward
29
+ *
30
+ * {@link PROTECTED_HEADERS} is stripped BEFORE the allow-list is consulted, so a
31
+ * permissive pattern (`x-*`, or a careless `*`) cannot re-open impersonation:
32
+ *
33
+ * | Header | What an app does with it | Why spoofing it matters |
34
+ * | -------------------------------- | ------------------------------- | ----------------------- |
35
+ * | `x-forwarded-access-token` | OBO auth (AppKit `asUser`) | Paste any workspace token and every call runs as its owner. The gate's verified email says nothing about who a pasted credential belongs to. |
36
+ * | `x-forwarded-user` | Caller identity | Impersonate another user. The gate sets this itself, from a verified session. |
37
+ * | `x-forwarded-email` | Caller identity | Same. |
38
+ * | `x-forwarded-preferred-username` | Display name from the IdP | Same. |
39
+ * | `x-forwarded-host` | The originally-requested host | Poison absolute URLs the app builds (the classic reset-link attack). |
40
+ * | `x-forwarded-proto` / `-port` | Original scheme / port | Convince the app a plaintext request arrived over TLS. |
41
+ * | `x-forwarded-for` | Client IP | Forge the audit trail, and fan out per-IP rate-limit keys (see below). |
42
+ * | `x-real-ip` | Client IP | Same. |
43
+ * | `x-request-id` | Request correlation UUID | Forge or collide trace ids, making logs unreliable. |
44
+ *
45
+ * The identity four are AppKit's OBO contract; the rest are the `X-Forwarded-*`
46
+ * set the Databricks Apps reverse proxy documents passing to an app
47
+ * ({@link https://docs.databricks.com/aws/en/dev-tools/databricks-apps/http-headers}),
48
+ * plus the conventional `x-forwarded-proto`/`-port`/`x-real-ip` an app or one of
49
+ * its libraries may read even though the table omits them.
50
+ *
51
+ * Stripping the `x-forwarded-*` transport trio is safe because `http-proxy-3` is
52
+ * configured with `xfwd: true` and re-adds them AFTER this policy runs - from the
53
+ * real socket, not from the caller's claim. The app therefore sees the honest
54
+ * (loopback) values instead of whatever the internet asserted. The gate reads the
55
+ * client IP for rate limiting from the raw inbound headers BEFORE stripping, and
56
+ * takes the RIGHTMOST `x-forwarded-for` entry, which is the only one a proxy
57
+ * appended rather than a client supplied.
58
+ *
59
+ * @module
60
+ */
61
+
62
+ import { pattern, token, type Predicate } from "@dbx-tools/shared-core";
63
+
64
+ /**
65
+ * Headers the gate strips UNCONDITIONALLY, before the allow-list is consulted -
66
+ * the platform-shaped set documented in this module's table.
67
+ *
68
+ * These answer WHO a request is and WHERE it came from, and on tunnel traffic
69
+ * only the gate may answer that: it injects {@link token.USER_ID_HEADER} /
70
+ * {@link token.USER_EMAIL_HEADER} itself after verifying a session, and the proxy
71
+ * re-derives the transport headers from the real socket.
72
+ *
73
+ * The identity names come from the shared `token` constants, so a renamed wire
74
+ * contract cannot leave a stale spelling here. `x-forwarded-preferred-username`
75
+ * has no constant because nothing in this repo READS it - it is listed precisely
76
+ * so a host app that does read it cannot be fed one.
77
+ */
78
+ export const PROTECTED_HEADERS: readonly string[] = [
79
+ token.ACCESS_TOKEN_HEADER,
80
+ token.USER_ID_HEADER,
81
+ token.USER_EMAIL_HEADER,
82
+ "x-forwarded-preferred-username",
83
+ "x-forwarded-host",
84
+ "x-forwarded-proto",
85
+ "x-forwarded-port",
86
+ "x-forwarded-for",
87
+ "x-real-ip",
88
+ "x-request-id",
89
+ ];
90
+
91
+ /**
92
+ * The `x-` headers forwarded when a deployment configures nothing.
93
+ *
94
+ * These are the header NAMESPACES this repo's own UI sends and its own server
95
+ * reads - Mastra thread/model routing (`x-mastra-thread-id`, `x-mastra-model`)
96
+ * and MLflow trace correlation (`x-mlflow-trace-id`) - so a dbx-tools app keeps
97
+ * working behind the tunnel with no configuration.
98
+ *
99
+ * Deliberately GLOBS rather than the exact constants from
100
+ * `@dbx-tools/shared-mastra`: importing them would make the gate - a
101
+ * transport-level component that has no other opinion about Mastra - depend on
102
+ * an agent package for three strings, and a namespace glob also covers the next
103
+ * header those packages add. The namespaces are the stable part of the contract.
104
+ */
105
+ export const DEFAULT_FORWARD_HEADERS: readonly string[] = [
106
+ "x-mastra-*",
107
+ "x-mlflow-*",
108
+ // Sent by fetch/XHR wrappers to mark an AJAX request; harmless and widely read.
109
+ "x-requested-with",
110
+ ];
111
+
112
+ /** A compiled inbound-header policy. Build one with {@link toHeaderPolicy}. */
113
+ export interface HeaderPolicy {
114
+ /** The allow-list entries backing this policy (for diagnostics and tests). */
115
+ readonly patterns: readonly string[];
116
+ /** Whether `name` survives on tunnel traffic. */
117
+ forwards(name: string): boolean;
118
+ /**
119
+ * Delete every disallowed header from a mutable Node header bag, returning the
120
+ * lower-cased names removed (for debug logging).
121
+ */
122
+ apply(headers: Record<string, unknown>): string[];
123
+ }
124
+
125
+ /**
126
+ * Compile the inbound-header policy. `configured` entries are UNIONED with
127
+ * {@link DEFAULT_FORWARD_HEADERS}; each may be a literal name, a glob
128
+ * (`x-myapp-*`), or a `/regex/`. Matching is case-insensitive.
129
+ */
130
+ export function toHeaderPolicy(configured: readonly string[] = []): HeaderPolicy {
131
+ const patterns = [...DEFAULT_FORWARD_HEADERS, ...configured];
132
+ const allowed: Predicate<string> = pattern.toPatternMatcher(patterns);
133
+ const protectedNames = new Set(PROTECTED_HEADERS);
134
+
135
+ const forwards = (name: string): boolean => {
136
+ const lower = name.toLowerCase();
137
+ if (protectedNames.has(lower)) return false;
138
+ if (!lower.startsWith("x-")) return true;
139
+ return allowed(lower);
140
+ };
141
+
142
+ return {
143
+ patterns,
144
+ forwards,
145
+ apply: (headers) => {
146
+ const removed: string[] = [];
147
+ for (const name of Object.keys(headers)) {
148
+ if (forwards(name)) continue;
149
+ delete headers[name];
150
+ removed.push(name.toLowerCase());
151
+ }
152
+ return removed;
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `tunnelInterceptor()` - the {@link Interceptor} that fronts an app with a public
3
+ * portr tunnel, consuming the {@link InterceptorContext} `@dbx-tools/appkit`'s
4
+ * `createApp` hands it.
5
+ *
6
+ * This is the in-process replacement for the old `dbxt-tunnel -- <cmd>` wrapper.
7
+ * Instead of the tunnel being the main process that spawns the app as a child, the
8
+ * APP is the main process and hands this interceptor its context. The interceptor:
9
+ *
10
+ * 1. applies the workspace host the context computed to `process.env`
11
+ * (`DATABRICKS_HOST`), so portr and any later SDK call agree on the workspace;
12
+ * 2. installs + launches portr, pointed at the app's PUBLIC port;
13
+ * 3. `bindProcess`es portr so the app and the tunnel live and die as one -
14
+ * signals pass through and either death tears the pair down (the
15
+ * concurrently-style supervision that used to be `superviseExit`);
16
+ * 4. registers an AppKit `shutdown` lifecycle handler so an orderly app shutdown
17
+ * also stops portr.
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.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
28
+ import { log } from "@dbx-tools/shared-core";
29
+ import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
30
+
31
+ const logger = log.logger("tunnel:interceptor");
32
+
33
+ /** Options for {@link tunnelInterceptor} (each also resolvable from env). */
34
+ export interface TunnelInterceptorOptions {
35
+ /** portr `<subdomain>.<server>` to serve on. Env `TUNNEL_PUBLIC_DOMAIN`. */
36
+ publicDomain?: string;
37
+ /** portr subdomain (else derived from {@link publicDomain}). */
38
+ subdomain?: string;
39
+ /**
40
+ * The PUBLIC port portr forwards to - the port the app itself listens on.
41
+ * Defaults to the Databricks Apps runtime contract `DATABRICKS_APP_PORT`
42
+ * (then `8000`), which is the port the platform routes to.
43
+ */
44
+ port?: number;
45
+ }
46
+
47
+ /** Resolve the public port portr should target: explicit, else the Apps contract. */
48
+ function resolvePublicPort(port?: number): number {
49
+ return port ?? Number(process.env.DATABRICKS_APP_PORT ?? 8000);
50
+ }
51
+
52
+ /**
53
+ * Build the tunnel {@link Interceptor}. Pass it to `createApp({ interceptor })`.
54
+ *
55
+ * A no-op (logs and returns) when no portr tunnel is configured - no `PORTR_TOKEN`
56
+ * or no resolvable `<subdomain>.<server>` - so an app can register it
57
+ * unconditionally and only actually tunnels where the deployment wired portr.
58
+ *
59
+ * @example
60
+ * import { createApp } from "@dbx-tools/appkit";
61
+ * import { tunnelInterceptor } from "@dbx-tools/tunnel";
62
+ *
63
+ * await createApp({
64
+ * plugins: [server({ host, staticPath })],
65
+ * interceptor: tunnelInterceptor(),
66
+ * });
67
+ */
68
+ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Interceptor {
69
+ return (ctx: InterceptorContext): void => {
70
+ // 1. Apply the computed workspace host so portr + the SDK agree. The context
71
+ // already resolved it (from the env / auto-config); set it only when it is
72
+ // known and not already present, so an explicit env wins.
73
+ if (ctx.env.databricksHost) {
74
+ process.env.DATABRICKS_HOST ??= ctx.env.databricksHost;
75
+ }
76
+
77
+ // 2. Resolve portr wiring. No token / domain -> no tunnel; leave the app alone.
78
+ // `resolvePortrConfig` already falls back to TUNNEL_PUBLIC_DOMAIN itself, so
79
+ // the explicit option is passed straight through.
80
+ const port = resolvePublicPort(options.port);
81
+ const portrConfig = resolvePortrConfig({
82
+ publicDomain: options.publicDomain,
83
+ subdomain: options.subdomain,
84
+ port,
85
+ });
86
+ if (!portrConfig) {
87
+ logger.info("no PORTR_TOKEN/TUNNEL_PUBLIC_DOMAIN - the app runs without a public tunnel");
88
+ return;
89
+ }
90
+
91
+ // 3. Install + launch portr, then bind it: the app and portr now share a fate.
92
+ const portrEnv = installPortr();
93
+ writePortrConfig(portrConfig, portrEnv);
94
+ const portr = startPortr(portrConfig, portrEnv);
95
+ ctx.bindProcess(portr);
96
+
97
+ // 4. An orderly AppKit shutdown also stops portr (belt-and-suspenders with the
98
+ // signal pass-through `bindProcess` already installed).
99
+ ctx.onLifecycle("shutdown", () => {
100
+ if (!portr.killed) portr.kill("SIGTERM");
101
+ });
102
+
103
+ logger.info(`tunnel bound: portr -> :${port} (${portrConfig.subdomain}.${portrConfig.server})`);
104
+ };
105
+ }
package/src/otp.ts ADDED
@@ -0,0 +1,137 @@
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 an HS256 token (via `jose`) carrying only the email. Its
12
+ * signing key is resolved by `./signing-key.ts`: `TUNNEL_AUTH_JWT_SECRET` when
13
+ * set, else a key persisted in the cache for 30 days so cookies survive the
14
+ * restarts a tunnel sees whenever the app it wraps reloads. A cookie signed
15
+ * before `TUNNEL_AUTH_SESSION_CUTOFF` is refused here as well as being orphaned
16
+ * by the cache key, so the force-clear switch holds even for a still-current key.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { createHash, randomInt, timingSafeEqual } from "node:crypto";
22
+ import { CacheManager } from "@databricks/appkit";
23
+ import { jwtVerify, SignJWT } from "jose";
24
+ import { signingKey } from "./signing-key.ts";
25
+
26
+ /** JWT issuer/audience so a token minted for this gate isn't accepted elsewhere. */
27
+ const JWT_AUD = "dbx-tools-tunnel-auth";
28
+
29
+ /** Cache-key prefix for pending codes, namespaced away from any other cache use. */
30
+ const CODE_PREFIX = "tunnel:otp:";
31
+
32
+ /** SHA-256 hex of a value. */
33
+ function sha256(value: string): string {
34
+ return createHash("sha256").update(value).digest("hex");
35
+ }
36
+
37
+ /** Constant-time compare of two equal-length hex digests. */
38
+ function safeEqualHex(a: string, b: string): boolean {
39
+ if (a.length !== b.length) return false;
40
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
41
+ }
42
+
43
+ interface CodeEntry {
44
+ hash: string;
45
+ attempts: number;
46
+ }
47
+
48
+ /** Result of {@link CodeStore.verify}. */
49
+ export type VerifyOutcome = "ok" | "invalid" | "expired" | "too-many-attempts";
50
+
51
+ /**
52
+ * Pending one-time codes, stored in AppKit's cache keyed by lowercased email.
53
+ * Expiry is the cache's TTL (no manual clock); a miss means expired-or-never.
54
+ */
55
+ export class CodeStore {
56
+ constructor(
57
+ private readonly ttlSeconds: number,
58
+ private readonly maxAttempts: number,
59
+ ) {}
60
+
61
+ private cache(): CacheManager {
62
+ return CacheManager.getInstanceSync();
63
+ }
64
+
65
+ private key(email: string): string {
66
+ return `${CODE_PREFIX}${email.toLowerCase()}`;
67
+ }
68
+
69
+ /**
70
+ * Generate, store (hashed, with the cache TTL), and RETURN a fresh 6-digit
71
+ * code. The caller emails the returned plaintext; only the hash is retained.
72
+ * Replaces any pending code for the address.
73
+ */
74
+ async issue(email: string): Promise<string> {
75
+ const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
76
+ const entry: CodeEntry = { hash: sha256(code), attempts: 0 };
77
+ await this.cache().set(this.key(email), entry, { ttl: this.ttlSeconds });
78
+ return code;
79
+ }
80
+
81
+ /**
82
+ * Check `code` for `email`. A cache miss is `expired` (TTL elapsed or never
83
+ * issued). Deletes the entry on success or when attempts are exhausted, so a
84
+ * code is single-use and can't be brute-forced past the cap. An attempt
85
+ * increments the stored counter (re-persisted with a fresh TTL window).
86
+ */
87
+ async verify(email: string, code: string): Promise<VerifyOutcome> {
88
+ const key = this.key(email);
89
+ const entry = await this.cache().get<CodeEntry>(key);
90
+ if (!entry) return "expired";
91
+ const attempts = entry.attempts + 1;
92
+ if (safeEqualHex(entry.hash, sha256(code))) {
93
+ await this.cache().delete(key);
94
+ return "ok";
95
+ }
96
+ if (attempts >= this.maxAttempts) {
97
+ await this.cache().delete(key);
98
+ return "too-many-attempts";
99
+ }
100
+ await this.cache().set(key, { ...entry, attempts }, { ttl: this.ttlSeconds });
101
+ return "invalid";
102
+ }
103
+ }
104
+
105
+ /** Mint a session JWT for `email`, expiring in `ttlSeconds`. */
106
+ export async function signSession(email: string, ttlSeconds: number): Promise<string> {
107
+ const { key } = await signingKey();
108
+ return new SignJWT({ email })
109
+ .setProtectedHeader({ alg: "HS256" })
110
+ .setSubject(email)
111
+ .setAudience(JWT_AUD)
112
+ .setIssuedAt()
113
+ .setExpirationTime(`${ttlSeconds}s`)
114
+ .sign(key);
115
+ }
116
+
117
+ /** Validate a session JWT, returning the email it was minted for, or `undefined`. */
118
+ export async function verifySession(token: string | undefined): Promise<string | undefined> {
119
+ if (!token) return undefined;
120
+ try {
121
+ const { key, cutoffMs } = await signingKey();
122
+ const { payload } = await jwtVerify(token, key, { audience: JWT_AUD });
123
+ // Belt and braces with the cutoff-scoped cache key: that alone already
124
+ // orphans older keys, but an operator who moved the cutoff while
125
+ // TUNNEL_AUTH_JWT_SECRET is set has no key rotation to rely on, and this
126
+ // check is what makes the switch work in that case too.
127
+ // Compared in whole SECONDS because `iat` has no finer resolution: against a
128
+ // millisecond cutoff, a cookie minted in the same second as it would be
129
+ // refused depending on sub-second rounding.
130
+ if (cutoffMs > 0 && (payload.iat === undefined || payload.iat < Math.floor(cutoffMs / 1000))) {
131
+ return undefined;
132
+ }
133
+ return typeof payload.email === "string" ? payload.email : undefined;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }