@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/README.md ADDED
@@ -0,0 +1,373 @@
1
+ # @dbx-tools/tunnel
2
+
3
+ Front an app with a public tunnel and an email one-time-code access gate,
4
+ in-process.
5
+
6
+ Built on [portr](https://github.com/amalshaji/portr). Use this library when an
7
+ app needs to be reachable from outside its network - a stakeholder demo, a
8
+ webhook sender that has to reach a dev build, an OAuth redirect that cannot point
9
+ at `localhost` - without publishing the app to anyone who learns the URL. It is
10
+ shaped for Databricks Apps (it honours the `DATABRICKS_APP_PORT` contract and
11
+ lets the platform's own front door through ungated) but the gate itself is
12
+ platform-neutral.
13
+
14
+ The tunnel plugs into `@dbx-tools/appkit`'s `createApp` through its INTERCEPTOR
15
+ context: `createApp({ interceptor: tunnelInterceptor() })` applies the computed
16
+ `DATABRICKS_HOST`, launches portr pointed at the app's public port, and binds it
17
+ so the app and portr live and die as one (concurrently-style - signals pass
18
+ through, either death tears the pair down). The app is the process; the tunnel
19
+ rides along inside it. Access is granted per email address against an allow-list,
20
+ verified by a code sent over [`@dbx-tools/email`](../../node/email); the gate
21
+ itself is the `authGate` AppKit plugin plus the `startProxy` reverse-proxy, both
22
+ exported here for an app that wants to gate the tunnelled traffic.
23
+
24
+ **Key features:**
25
+
26
+ - Consumed in-process via the `createApp` interceptor context - no wrapper CLI,
27
+ no separate process: `createApp({ interceptor: tunnelInterceptor() })`. A no-op
28
+ when no `PORTR_TOKEN` / `TUNNEL_PUBLIC_DOMAIN` is set, so it is safe to register
29
+ unconditionally.
30
+ - Branded from the repo-wide brand context: the code email's accent colour, font,
31
+ logo, and display name come from the app's own `branding/brand.yaml` (via
32
+ `@dbx-tools/core`'s `loadBrandContext()`), falling back to the dbx-tools
33
+ default - so the sign-in email looks like the app it fronts with nothing to
34
+ configure. `TUNNEL_AUTH_BRAND_NAME` overrides just the name.
35
+ - Conventional one-time-code copy, so platform autofill works: the email is
36
+ `Your verification code is: / <code> / This code expires in N minutes`, and the
37
+ code input carries `autocomplete="one-time-code"`. iOS, Gmail, Outlook, and
38
+ Android detect a code from that shape and offer it directly from the
39
+ notification; novel phrasing is what breaks the detection. Both MIME parts carry
40
+ the code as visible text, never an image, and no trailer follows the copy.
41
+ - The code rides in the SUBJECT and the preheader, not only the body:
42
+ `123456 is your verification code`. Mobile autofill reads an incoming
43
+ NOTIFICATION, and a notification contains the sender, the subject, and a short
44
+ snippet - nothing else - so a code that lives only in the body is unreachable
45
+ however cleanly the body is shaped. See
46
+ [Why the code is in the subject](#why-the-code-is-in-the-subject).
47
+ - The two MIME parts are built separately, on purpose. The HTML part is the full
48
+ branded template with the code as a large styled heading; the `text/plain` part
49
+ is authored directly, keeping the prompt and the code on ONE line
50
+ (`Your verification code is: 123456`) because that is the shape client code
51
+ detection reads most reliably. A generated text part cannot hold it - the text
52
+ part is a rendering of the HTML, so the heading's CSS margin arrives as blank
53
+ lines and autofill stops being offered while the HTML still looks perfect. See
54
+ `codeEmailTextBody` in the `app` module.
55
+ - The sign-in code is SYSTEM mail: it sends from `no-reply@EMAIL_DOMAIN` (or
56
+ `EMAIL_SYSTEM_FROM`), never a person's address, since a reply to a
57
+ machine-generated code reaches nobody. `EMAIL_FROM` is not required - see
58
+ [`@dbx-tools/email`](../../node/email#sender-addresses).
59
+ - Email one-time-code gate: a 6-digit code stored as a SHA-256 hash with an
60
+ attempt counter, verified in constant time, in AppKit's `CacheManager` (Lakebase
61
+ when a database is bound to the deployment, else memory) so TTL expiry and
62
+ eviction are the cache's job.
63
+ - The gate resolves Lakebase for itself, so its cache is actually persistent. The
64
+ gate is its own tiny AppKit app with no `lakebase()` plugin (it has no server to
65
+ mount routes on), and AppKit only chooses Lakebase for the cache when a pool can
66
+ be built from `LAKEBASE_ENDPOINT` **and** `PGHOST` **and** `PGDATABASE` - while a
67
+ Databricks App `postgres` binding supplies only the first. The gate fills in the
68
+ rest at boot, and skips entirely when nothing is bound rather than creating
69
+ infrastructure on someone else's behalf.
70
+ - HS256 session JWT (via `jose`) carrying only the email, signed with a key that
71
+ is PERSISTED in AppKit's cache for 30 days - so a signed-in browser stays signed
72
+ in across the restarts a tunnel sees whenever the app it wraps reloads. An
73
+ operator-held `TUNNEL_AUTH_JWT_SECRET` still wins. `TUNNEL_AUTH_SESSION_CUTOFF`
74
+ is the log-everyone-out switch, and takes a relative duration (`-30d`) as
75
+ readily as a date.
76
+ - Allow-list patterns in three shapes, matched in order: a domain shortcut
77
+ (`example.com`, `@example.com`), a shell-style glob (`*@example.com`), or a
78
+ regex literal (`/^ops-.*@example\.com$/`). An empty list allows nobody.
79
+ - Per-email and per-IP fixed-window rate limiting, plus anti-enumeration: a code
80
+ request always answers `{ ok: true }`, whether or not the address is allowed.
81
+ - Inbound `x-` headers are stripped by default and re-allowed by pattern, so a
82
+ public caller cannot spoof the headers the app trusts - above all
83
+ `x-forwarded-access-token`, which would otherwise let anyone drive the app's
84
+ workspace calls with a pasted token. Add an app's own headers with
85
+ `TUNNEL_FORWARD_HEADERS`.
86
+ - Platform traffic passes through UNGATED. The gate distinguishes the portr
87
+ client (a loopback source address, same container) from the hosting platform's
88
+ front door (a non-loopback container-network address), so health checks and the
89
+ workspace UI keep working while public tunnel traffic is gated.
90
+ - SPA-aware gating: static assets and the login routes stay open so the browser
91
+ can load the client and render the login form; every other `/api/*` needs a
92
+ valid session cookie or gets `401`. WebSocket upgrades are gated the same way.
93
+ - Supervised teardown - `tunnelInterceptor` binds portr through the `createApp`
94
+ interceptor context, so the app and the tunnel are tied together: if either
95
+ exits, `bindProcess` brings the whole set down and passes signals through.
96
+ - The gate fails fast when email is not configured for SMTP, because a gate that
97
+ cannot send codes locks everyone out (see `startGateApp` in the `app` module).
98
+
99
+ ## Why This Over An Ad-Hoc Tunnel
100
+
101
+ A bare tunnel (`ngrok`, `portr` on its own) makes the app reachable by anyone
102
+ with the URL. This package keeps the tunnel but puts a gate in front of it,
103
+ reusing what the app already has: AppKit's cache for code storage, the
104
+ `@dbx-tools/email` transport for delivery, and the app's own `From` policy.
105
+ Because it rides inside the app's own `createApp`, there is no second process to
106
+ supervise and no wrapper command to thread flags through.
107
+
108
+ ## Run It
109
+
110
+ ```ts
111
+ import { createApp } from "@dbx-tools/appkit";
112
+ import { server } from "@databricks/appkit";
113
+ import { interceptor } from "@dbx-tools/tunnel";
114
+
115
+ const { tunnelInterceptor } = interceptor;
116
+
117
+ await createApp.createApp({
118
+ plugins: [server({ host, staticPath })],
119
+ // Applies DATABRICKS_HOST, launches portr at the app's public port, and binds
120
+ // it to the app. No-op when no PORTR_TOKEN / TUNNEL_PUBLIC_DOMAIN is set.
121
+ interceptor: tunnelInterceptor(),
122
+ });
123
+ ```
124
+
125
+ `tunnelInterceptor(opts)` takes optional `publicDomain` / `subdomain` / `port`;
126
+ each falls back to env (and `port` to the `DATABRICKS_APP_PORT` contract), so a
127
+ deployment usually passes nothing and configures through the environment. The
128
+ OTP gate is a separate concern - see [Use The Gate](#use-the-gate) to mount
129
+ `authGate` + `startProxy` for gated traffic.
130
+
131
+ ## Options
132
+
133
+ The interceptor reads portr wiring from the environment; the gate (the `authGate`
134
+ plugin) reads its own settings the same way, so a deployment configures both
135
+ without touching code.
136
+
137
+ | Env | Default |
138
+ | ---------------------------- | ---------------------------- |
139
+ | `TUNNEL_AUTH_ALLOW` | empty (allow nobody) |
140
+ | `TUNNEL_AUTH_SUBJECT` | `Your verification code` |
141
+ | `TUNNEL_AUTH_BRAND_NAME` | the brand context `name` |
142
+ | `TUNNEL_AUTH_MESSAGE` | `Your verification code is:` |
143
+ | `TUNNEL_AUTH_SESSION_TTL` | `2592000` (30 days) |
144
+ | `TUNNEL_AUTH_CODE_TTL` | `600` (10 minutes) |
145
+ | `TUNNEL_AUTH_SESSION_CUTOFF` | unset (no cutoff) |
146
+ | `TUNNEL_PUBLIC_DOMAIN` | - (no tunnel when unset) |
147
+ | `TUNNEL_FORWARD_HEADERS` | the built-in `x-` allow-list |
148
+ | `TUNNEL_AUTH_JWT_SECRET` | an ephemeral per-process key |
149
+
150
+ Every variable is `TUNNEL_`-prefixed because the tunnel shares one environment
151
+ with the app it fronts, so a generic name is one the app may already be using.
152
+ The earlier unprefixed spellings - `AUTH_SUBJECT`, `AUTH_BRAND_NAME`,
153
+ `AUTH_MESSAGE`, `AUTH_SESSION_TTL`, `AUTH_CODE_TTL`, `AUTH_JWT_SECRET`,
154
+ `EMAIL_AUTH_ALLOW`, `PUBLIC_DOMAIN`, plus `TUNNEL_AUTH_SESSION_EPOCH` from before
155
+ the cutoff rename - are still read as deprecated aliases, with the `TUNNEL_` name
156
+ winning when both are set, so an existing deployment needs no coordinated rename.
157
+ `PORTR_TOKEN` / `PORTR_SERVER` keep their names: that namespace belongs to portr
158
+ itself, as does `DATABRICKS_APP_PORT`, which the platform sets and the tunnel
159
+ honours.
160
+
161
+ ## Sessions That Survive A Restart
162
+
163
+ The signing key decides whether an already-issued session COOKIE still verifies,
164
+ so where that key comes from is what decides whether a restart signs everyone out.
165
+ Resolution order:
166
+
167
+ 1. **`TUNNEL_AUTH_JWT_SECRET`**, when set. The right answer for a fleet: an
168
+ operator-held secret needs no shared cache, and it survives a cache flush.
169
+ 2. **A key persisted in AppKit's cache for 30 days.** With a persistent
170
+ `CacheStorage` (Lakebase) the key outlives the process, so cookies stay valid
171
+ across restarts - which a tunnel does often, since it restarts whenever the app
172
+ it wraps reloads. On the default in-memory cache the key is per-process, the
173
+ same as having no secret at all.
174
+
175
+ Getting that persistence is not automatic, and the gate does the work at boot
176
+ via `lakebaseResolver.applyLakebaseEnv()`: it turns `LAKEBASE_ENDPOINT` into the
177
+ `PGHOST` / `PGDATABASE` / `PGUSER` the cache's pool also needs. Without them
178
+ AppKit cannot build the pool, silently uses an in-memory cache, and every
179
+ redeploy signs everyone out - so the startup log says which one you got
180
+ (`lakebase resolved for the gate cache`, or `the gate cache stays in memory`).
181
+ Bind a `postgres` resource to the app to get the persistent path.
182
+
183
+ 3. **An ephemeral per-process key**, when there is no secret and no reachable
184
+ cache. Sessions do not survive a restart, but the gate still serves: the key
185
+ only validates an ALREADY-issued session, so losing it costs sessions, never
186
+ admission. A caller still needs a code delivered to an allow-listed address.
187
+
188
+ The cached key is read, generated-and-stored, then **re-read**. Two instances
189
+ booting together both miss the cache, so both generate; adopting whatever is
190
+ STORED afterwards is what makes them converge on one key instead of each trusting
191
+ the one it minted. Set `TUNNEL_AUTH_JWT_SECRET` to remove the race entirely.
192
+
193
+ `TUNNEL_AUTH_SESSION_TTL` defaults to the same 30 days the key is stored for, on
194
+ purpose - a key that expired before the cookies it signed would sign everyone out
195
+ for no reason.
196
+
197
+ ## Why The Code Is In The Subject
198
+
199
+ The subject line the gate SENDS is `123456 is your verification code` -
200
+ `--subject` is the template the code is spliced into, not the literal line.
201
+
202
+ Mobile autofill does not read the email; it reads the NOTIFICATION. iOS scans
203
+ incoming notification text for a code and offers to fill it - natively for
204
+ Messages and Mail, and since iOS 26 for any app's notification, which is what
205
+ finally made Gmail work. A notification carries the sender, the subject, and a
206
+ short snippet. That is all. A code sitting in the body is invisible to it no
207
+ matter how carefully the body is formatted, which is why a perfectly shaped
208
+ `text/plain` part alone produced no prompt in Gmail.
209
+
210
+ So the code goes in both strings a notification actually shows:
211
+
212
+ - **The subject**, code FIRST, because a notification and an inbox row both
213
+ truncate: `123456 is your verification code` survives the cut wherever it lands,
214
+ and keeps the code in the same sentence as the words the heuristics look for.
215
+ A subject that does not use the conventional `Your ...` phrasing is treated as
216
+ deliberate and only prefixed (`123456 - Acme Ops access`).
217
+ - **The preheader**, the hidden snippet a client shows beside the subject and puts
218
+ in the notification body, repeating the prompt with the code
219
+ (`Your verification code is: 123456`).
220
+
221
+ The body keeps the code as a large styled heading regardless, for a recipient
222
+ reading the mail rather than a notification. This is deliberately NOT Apple's
223
+ domain-bound `@domain #code` trailer, which binds a code to a single origin;
224
+ subject + preheader works across clients and needs no origin.
225
+
226
+ ### Signing everyone out
227
+
228
+ `TUNNEL_AUTH_SESSION_CUTOFF` (or `sessionCutoff` on the `authGate` config)
229
+ invalidates every session issued before a given moment:
230
+
231
+ ```sh
232
+ TUNNEL_AUTH_SESSION_CUTOFF="-30d" ... # a relative duration
233
+ TUNNEL_AUTH_SESSION_CUTOFF="2026-08-02" ... # a date
234
+ TUNNEL_AUTH_SESSION_CUTOFF="now" ... # sign everyone out on this boot
235
+ ```
236
+
237
+ The value goes through `@dbx-tools/shared-core`'s `object.toDate`, so a date, an
238
+ ISO instant, epoch seconds or millis from `date +%s`, and a relative duration
239
+ (`-30d`, `12 hours ago`) all work - the relative spelling being the one an
240
+ operator usually wants, since "sign out anything older than a month" needs no
241
+ timestamp arithmetic. It works two ways at once, so it holds however the key was
242
+ resolved: the cutoff is part of the key's CACHE KEY (moving it orphans the
243
+ previous key), and it is also checked against each token's `iat` (which is what
244
+ makes it bite when `TUNNEL_AUTH_JWT_SECRET` is set and there is no key to
245
+ rotate).
246
+
247
+ A FUTURE date is clamped to now, because an unclamped one would refuse the
248
+ sessions it is about to mint as well as the old ones - an app nobody can sign in
249
+ to, from a mistyped year. An unparseable value is ignored with a warning rather
250
+ than failing startup: this is the switch that gets a fleet back in.
251
+
252
+ ## Inbound Header Policy
253
+
254
+ Tunnel traffic arrives from the public internet, so every header on it is
255
+ attacker-controlled - and the headers an app trusts are exactly the ones a caller
256
+ must not be able to write, because the app cannot tell a header the Databricks
257
+ front door set from one a browser typed.
258
+
259
+ Enumerating headers to remove is a losing game (a deny-list is only correct until
260
+ the platform adds a header), so the policy is inverted: **every `x-`-prefixed
261
+ request header is stripped from tunnel traffic unless a pattern allows it.** A
262
+ header nobody thought about is removed rather than trusted. Non-`x-` headers -
263
+ `content-type`, `accept`, `authorization`, `cookie` - are the app's normal input
264
+ and pass through untouched.
265
+
266
+ The default allow-list covers the `x-` namespaces this repo's own client sends
267
+ and its own server reads, so a dbx-tools app works behind the tunnel with no
268
+ configuration:
269
+
270
+ | Pattern | Why |
271
+ | ------------------ | --------------------------------------------------- |
272
+ | `x-mastra-*` | thread and model routing for `@dbx-tools/ui-mastra` |
273
+ | `x-mlflow-*` | MLflow trace correlation for feedback |
274
+ | `x-requested-with` | the conventional AJAX marker |
275
+
276
+ Add an app's own headers with `TUNNEL_FORWARD_HEADERS` (or `forwardHeaders` on
277
+ `startProxy`). Each entry is a literal name, a shell-style glob, or a `/regex/` -
278
+ the same three shapes the email allow-list takes - and the configured list is
279
+ UNIONED with the defaults, so extending it never silently breaks the built-in
280
+ surfaces:
281
+
282
+ ```sh
283
+ TUNNEL_FORWARD_HEADERS="x-acme-*, /^x-trace-/, x-tenant" ...
284
+ ```
285
+
286
+ Some headers no pattern can forward. These decide who a request is and where it
287
+ came from, and on tunnel traffic only the gate may answer that:
288
+
289
+ | Header | Why it is never forwarded |
290
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
291
+ | `x-forwarded-access-token` | OBO auth. A pasted workspace token would make every call in the app run as its owner; a verified email proves nothing about who a credential belongs to. |
292
+ | `x-forwarded-user`, `-email` | Caller identity. The gate sets these itself, from a verified session. |
293
+ | `x-forwarded-preferred-username` | Display identity from the IdP. |
294
+ | `x-forwarded-host`, `-proto`, `-port` | Original host/scheme/port. Spoofing them poisons absolute URLs the app builds, or makes a plaintext request look like TLS. |
295
+ | `x-forwarded-for`, `x-real-ip` | Client IP. Spoofing forges the audit trail and gives a caller a fresh rate-limit bucket per request. |
296
+ | `x-request-id` | Request correlation UUID. Forged or colliding ids make logs unreliable. |
297
+
298
+ The identity headers are AppKit's OBO contract; the rest are the
299
+ [`X-Forwarded-*` set Databricks Apps documents passing to an
300
+ app](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/http-headers),
301
+ plus the conventional `x-forwarded-proto`/`-port`/`x-real-ip` a library may read
302
+ anyway. Dropping the transport headers costs nothing: the proxy re-adds them from
303
+ the real socket after the policy runs, so the app sees the honest values instead
304
+ of the caller's claim. Rate limiting reads the client IP before stripping, and
305
+ takes the **rightmost** `x-forwarded-for` entry - the only one a proxy appended
306
+ rather than a client supplied.
307
+
308
+ ## Use The Gate
309
+
310
+ The gate is an AppKit plugin, so an app that wants the OTP flow can mount it
311
+ directly - with or without the portr tunnel. It registers no routes - it exposes
312
+ handlers the caller invokes (drive them from the `startProxy` reverse-proxy, or
313
+ your own server) - and it takes a `sendCode` callback because delivering mail is
314
+ the one thing it cannot resolve on its own:
315
+
316
+ ```ts
317
+ import { createApp } from "@dbx-tools/appkit";
318
+ import { authGate } from "@dbx-tools/tunnel";
319
+ import { brand, email, sender, transport } from "@dbx-tools/email";
320
+
321
+ const handle = await createApp({
322
+ plugins: [
323
+ email({ brand: brand.defaultEmailBrand }),
324
+ authGate({
325
+ allow: ["example.com"],
326
+ sendCode: async (to, code, opts) => {
327
+ const runtime = transport.getEmailRuntime();
328
+ await transport.sendEmail(
329
+ {
330
+ to: [to],
331
+ subject: opts.subject,
332
+ body: `${opts.message}\n\n## ${code}`,
333
+ },
334
+ // The app's configured sender; a code email has no on-behalf-of user.
335
+ sender.resolveSenderAddress(runtime.config, undefined),
336
+ );
337
+ },
338
+ }),
339
+ ],
340
+ });
341
+
342
+ // The gate exposes handlers rather than routes - call them from your own server.
343
+ const status = await handle.authGate.status(sessionCookieValue);
344
+ ```
345
+
346
+ `sendCode` is the one thing the plugin cannot resolve on its own. The `app`
347
+ module's `startGateApp()` wires this same callback and derives the email styling
348
+ from the brand context, so mounting the plugin directly is the only case that
349
+ needs it by hand.
350
+
351
+ ## Modules
352
+
353
+ - `interceptor` - `tunnelInterceptor()`, the `createApp` interceptor that applies
354
+ `DATABRICKS_HOST`, launches portr, and binds it to the app.
355
+ - `plugin` - `authGate()`, its config/env resolution, and the `AuthGateApi`
356
+ handlers the proxy calls in-process.
357
+ - `proxy` - the public-port reverse proxy: loopback-vs-platform classification,
358
+ open login routes, session enforcement, and WebSocket forwarding.
359
+ - `otp` - the `CacheManager`-backed code store and the session JWT.
360
+ - `signingKey` - the cache-persisted HS256 session key (30-day TTL, get/generate/
361
+ re-read convergence) and the `TUNNEL_AUTH_SESSION_CUTOFF` force-clear cutoff.
362
+ - `allowlist` - email domain / glob / regex matching and `looksLikeEmail`.
363
+ - `headers` - the inbound-header allow-list: `toHeaderPolicy()`,
364
+ `DEFAULT_FORWARD_HEADERS`, and the `PROTECTED_HEADERS` no pattern can forward.
365
+ - `rate-limit` - the in-memory fixed-window limiter (single-instance only; not
366
+ distributed).
367
+ - `portr` - portr install, config rendering, and child launch.
368
+ - `env` - the environment-variable names, each with its deprecated aliases.
369
+ - `app` - boots the minimal gate AppKit app and returns the `AuthGateApi`.
370
+
371
+ Browser-safe login wire schemas (the request/verify payloads and the session
372
+ cookie name) live in [`@dbx-tools/shared-email`](../../shared/email); the React
373
+ login surface is in [`@dbx-tools/ui-email`](../../ui/email).
package/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as allowlist from "./src/allowlist.ts";
6
+ export * as app from "./src/app.ts";
7
+ export * as env from "./src/env.ts";
8
+ export * as headers from "./src/headers.ts";
9
+ export * as interceptor from "./src/interceptor.ts";
10
+ export * as otp from "./src/otp.ts";
11
+ export * as plugin from "./src/plugin.ts";
12
+ export * as portr from "./src/portr.ts";
13
+ export * as proxy from "./src/proxy.ts";
14
+ export * as rateLimit from "./src/rate-limit.ts";
15
+ export * as signingKey from "./src/signing-key.ts";
16
+ export { ALLOW_ENV, SUBJECT_ENV, BRAND_NAME_ENV, MESSAGE_ENV, SESSION_TTL_ENV, CODE_TTL_ENV, JWT_SECRET_ENV, SESSION_CUTOFF_ENV, PUBLIC_DOMAIN_ENV, INSECURE_ENV, FORWARD_HEADERS_ENV } from "./src/env.ts";
17
+ export { PROTECTED_HEADERS, DEFAULT_FORWARD_HEADERS } from "./src/headers.ts";
18
+ export type { HeaderPolicy } from "./src/headers.ts";
19
+ export type { TunnelInterceptorOptions } from "./src/interceptor.ts";
20
+ export { CodeStore } from "./src/otp.ts";
21
+ export type { VerifyOutcome } from "./src/otp.ts";
22
+ export { AuthGatePlugin, authGate } from "./src/plugin.ts";
23
+ export type { AuthGateConfig, SendCodeOptions, ResolvedAuthGateConfig, AuthGateApi } from "./src/plugin.ts";
24
+ export type { PortrConfig } from "./src/portr.ts";
25
+ export type { ProxyOptions } from "./src/proxy.ts";
26
+ export { RateLimiter } from "./src/rate-limit.ts";
27
+ export { KEY_TTL_SECONDS } from "./src/signing-key.ts";
28
+ export type { SigningKey } from "./src/signing-key.ts";
package/lib/index.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export * as allowlist from "./src/allowlist.ts";
2
+ export * as app from "./src/app.ts";
3
+ export * as env from "./src/env.ts";
4
+ export * as headers from "./src/headers.ts";
5
+ export * as interceptor from "./src/interceptor.ts";
6
+ export * as otp from "./src/otp.ts";
7
+ export * as plugin from "./src/plugin.ts";
8
+ export * as portr from "./src/portr.ts";
9
+ export * as proxy from "./src/proxy.ts";
10
+ export * as rateLimit from "./src/rate-limit.ts";
11
+ export * as signingKey from "./src/signing-key.ts";
12
+ export { ALLOW_ENV, SUBJECT_ENV, BRAND_NAME_ENV, MESSAGE_ENV, SESSION_TTL_ENV, CODE_TTL_ENV, JWT_SECRET_ENV, SESSION_CUTOFF_ENV, PUBLIC_DOMAIN_ENV, INSECURE_ENV, FORWARD_HEADERS_ENV } from "./src/env.ts";
13
+ export { PROTECTED_HEADERS, DEFAULT_FORWARD_HEADERS } from "./src/headers.ts";
14
+ export type { HeaderPolicy } from "./src/headers.ts";
15
+ export type { TunnelInterceptorOptions } from "./src/interceptor.ts";
16
+ export { CodeStore } from "./src/otp.ts";
17
+ export type { VerifyOutcome } from "./src/otp.ts";
18
+ export { AuthGatePlugin, authGate } from "./src/plugin.ts";
19
+ export type { AuthGateConfig, SendCodeOptions, ResolvedAuthGateConfig, AuthGateApi } from "./src/plugin.ts";
20
+ export type { PortrConfig } from "./src/portr.ts";
21
+ export type { ProxyOptions } from "./src/proxy.ts";
22
+ export { RateLimiter } from "./src/rate-limit.ts";
23
+ export { KEY_TTL_SECONDS } from "./src/signing-key.ts";
24
+ export type { SigningKey } from "./src/signing-key.ts";
package/lib/index.js ADDED
@@ -0,0 +1,21 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+ export * as allowlist from "./src/allowlist.js";
5
+ export * as app from "./src/app.js";
6
+ export * as env from "./src/env.js";
7
+ export * as headers from "./src/headers.js";
8
+ export * as interceptor from "./src/interceptor.js";
9
+ export * as otp from "./src/otp.js";
10
+ export * as plugin from "./src/plugin.js";
11
+ export * as portr from "./src/portr.js";
12
+ export * as proxy from "./src/proxy.js";
13
+ export * as rateLimit from "./src/rate-limit.js";
14
+ export * as signingKey from "./src/signing-key.js";
15
+ export { ALLOW_ENV, SUBJECT_ENV, BRAND_NAME_ENV, MESSAGE_ENV, SESSION_TTL_ENV, CODE_TTL_ENV, JWT_SECRET_ENV, SESSION_CUTOFF_ENV, PUBLIC_DOMAIN_ENV, INSECURE_ENV, FORWARD_HEADERS_ENV } from "./src/env.js";
16
+ export { PROTECTED_HEADERS, DEFAULT_FORWARD_HEADERS } from "./src/headers.js";
17
+ export { CodeStore } from "./src/otp.js";
18
+ export { AuthGatePlugin, authGate } from "./src/plugin.js";
19
+ export { RateLimiter } from "./src/rate-limit.js";
20
+ export { KEY_TTL_SECONDS } from "./src/signing-key.js";
21
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwyQ0FBMkM7QUFDM0MsbURBQW1EO0FBQ25ELHdFQUF3RTtBQUV4RSxPQUFPLEtBQUssU0FBUyxNQUFNLG9CQUFvQixDQUFDO0FBQ2hELE9BQU8sS0FBSyxHQUFHLE1BQU0sY0FBYyxDQUFDO0FBQ3BDLE9BQU8sS0FBSyxHQUFHLE1BQU0sY0FBYyxDQUFDO0FBQ3BDLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxLQUFLLFdBQVcsTUFBTSxzQkFBc0IsQ0FBQztBQUNwRCxPQUFPLEtBQUssR0FBRyxNQUFNLGNBQWMsQ0FBQztBQUNwQyxPQUFPLEtBQUssTUFBTSxNQUFNLGlCQUFpQixDQUFDO0FBQzFDLE9BQU8sS0FBSyxLQUFLLE1BQU0sZ0JBQWdCLENBQUM7QUFDeEMsT0FBTyxLQUFLLEtBQUssTUFBTSxnQkFBZ0IsQ0FBQztBQUN4QyxPQUFPLEtBQUssU0FBUyxNQUFNLHFCQUFxQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxVQUFVLE1BQU0sc0JBQXNCLENBQUM7QUFDbkQsT0FBTyxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsY0FBYyxFQUFFLFdBQVcsRUFBRSxlQUFlLEVBQUUsWUFBWSxFQUFFLGNBQWMsRUFBRSxrQkFBa0IsRUFBRSxpQkFBaUIsRUFBRSxZQUFZLEVBQUUsbUJBQW1CLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFDNU0sT0FBTyxFQUFFLGlCQUFpQixFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFHOUUsT0FBTyxFQUFFLFNBQVMsRUFBRSxNQUFNLGNBQWMsQ0FBQztBQUV6QyxPQUFPLEVBQUUsY0FBYyxFQUFFLFFBQVEsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBSTNELE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSxxQkFBcUIsQ0FBQztBQUNsRCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sc0JBQXNCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBHRU5FUkFURUQgYnkgcHJvamVuIHdhdGNoIC0gRE8gTk9UIEVESVQuXG4vLyBSZWdlbmVyYXRlZCBmcm9tIHRoZSBleHBvcnRpbmcgbW9kdWxlcyBpbiAuL3NyYy5cbi8vIEhhbmQgZWRpdHMgYXJlIG92ZXJ3cml0dGVuIG9uIHRoZSBuZXh0IHdhdGNoOyB0aGlzIGZpbGUgaXMgcmVhZC1vbmx5LlxuXG5leHBvcnQgKiBhcyBhbGxvd2xpc3QgZnJvbSBcIi4vc3JjL2FsbG93bGlzdC50c1wiO1xuZXhwb3J0ICogYXMgYXBwIGZyb20gXCIuL3NyYy9hcHAudHNcIjtcbmV4cG9ydCAqIGFzIGVudiBmcm9tIFwiLi9zcmMvZW52LnRzXCI7XG5leHBvcnQgKiBhcyBoZWFkZXJzIGZyb20gXCIuL3NyYy9oZWFkZXJzLnRzXCI7XG5leHBvcnQgKiBhcyBpbnRlcmNlcHRvciBmcm9tIFwiLi9zcmMvaW50ZXJjZXB0b3IudHNcIjtcbmV4cG9ydCAqIGFzIG90cCBmcm9tIFwiLi9zcmMvb3RwLnRzXCI7XG5leHBvcnQgKiBhcyBwbHVnaW4gZnJvbSBcIi4vc3JjL3BsdWdpbi50c1wiO1xuZXhwb3J0ICogYXMgcG9ydHIgZnJvbSBcIi4vc3JjL3BvcnRyLnRzXCI7XG5leHBvcnQgKiBhcyBwcm94eSBmcm9tIFwiLi9zcmMvcHJveHkudHNcIjtcbmV4cG9ydCAqIGFzIHJhdGVMaW1pdCBmcm9tIFwiLi9zcmMvcmF0ZS1saW1pdC50c1wiO1xuZXhwb3J0ICogYXMgc2lnbmluZ0tleSBmcm9tIFwiLi9zcmMvc2lnbmluZy1rZXkudHNcIjtcbmV4cG9ydCB7IEFMTE9XX0VOViwgU1VCSkVDVF9FTlYsIEJSQU5EX05BTUVfRU5WLCBNRVNTQUdFX0VOViwgU0VTU0lPTl9UVExfRU5WLCBDT0RFX1RUTF9FTlYsIEpXVF9TRUNSRVRfRU5WLCBTRVNTSU9OX0NVVE9GRl9FTlYsIFBVQkxJQ19ET01BSU5fRU5WLCBJTlNFQ1VSRV9FTlYsIEZPUldBUkRfSEVBREVSU19FTlYgfSBmcm9tIFwiLi9zcmMvZW52LnRzXCI7XG5leHBvcnQgeyBQUk9URUNURURfSEVBREVSUywgREVGQVVMVF9GT1JXQVJEX0hFQURFUlMgfSBmcm9tIFwiLi9zcmMvaGVhZGVycy50c1wiO1xuZXhwb3J0IHR5cGUgeyBIZWFkZXJQb2xpY3kgfSBmcm9tIFwiLi9zcmMvaGVhZGVycy50c1wiO1xuZXhwb3J0IHR5cGUgeyBUdW5uZWxJbnRlcmNlcHRvck9wdGlvbnMgfSBmcm9tIFwiLi9zcmMvaW50ZXJjZXB0b3IudHNcIjtcbmV4cG9ydCB7IENvZGVTdG9yZSB9IGZyb20gXCIuL3NyYy9vdHAudHNcIjtcbmV4cG9ydCB0eXBlIHsgVmVyaWZ5T3V0Y29tZSB9IGZyb20gXCIuL3NyYy9vdHAudHNcIjtcbmV4cG9ydCB7IEF1dGhHYXRlUGx1Z2luLCBhdXRoR2F0ZSB9IGZyb20gXCIuL3NyYy9wbHVnaW4udHNcIjtcbmV4cG9ydCB0eXBlIHsgQXV0aEdhdGVDb25maWcsIFNlbmRDb2RlT3B0aW9ucywgUmVzb2x2ZWRBdXRoR2F0ZUNvbmZpZywgQXV0aEdhdGVBcGkgfSBmcm9tIFwiLi9zcmMvcGx1Z2luLnRzXCI7XG5leHBvcnQgdHlwZSB7IFBvcnRyQ29uZmlnIH0gZnJvbSBcIi4vc3JjL3BvcnRyLnRzXCI7XG5leHBvcnQgdHlwZSB7IFByb3h5T3B0aW9ucyB9IGZyb20gXCIuL3NyYy9wcm94eS50c1wiO1xuZXhwb3J0IHsgUmF0ZUxpbWl0ZXIgfSBmcm9tIFwiLi9zcmMvcmF0ZS1saW1pdC50c1wiO1xuZXhwb3J0IHsgS0VZX1RUTF9TRUNPTkRTIH0gZnJvbSBcIi4vc3JjL3NpZ25pbmcta2V5LnRzXCI7XG5leHBvcnQgdHlwZSB7IFNpZ25pbmdLZXkgfSBmcm9tIFwiLi9zcmMvc2lnbmluZy1rZXkudHNcIjtcbiJdfQ==
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Unified access allow-list matching for the email-OTP gate.
3
+ *
4
+ * Each pattern in the configured list is one of three shapes:
5
+ *
6
+ * - **domain shortcut** - `example.com` or `@example.com`: matches any
7
+ * address whose domain equals it. This is the gate's OWN semantic (a bare
8
+ * value means "the domain", not "the whole address"), so it is handled here.
9
+ * - **glob** - contains `*` or `?`, e.g. `*@example.com`: matched against the
10
+ * WHOLE address with shell-style wildcards.
11
+ * - **regex** - wrapped in slashes, `/.../ [flags]`: tested against the whole
12
+ * address. An invalid regex never matches (it is skipped with a warning
13
+ * rather than throwing).
14
+ *
15
+ * Only the first shape is this module's business: the glob and regex shapes are
16
+ * delegated to `@dbx-tools/shared-core`'s {@link pattern.toPattern}, which is
17
+ * where that compilation lives for every allow-list in the repo (the tunnel's
18
+ * inbound-header policy uses the same one). Matching is case-insensitive
19
+ * throughout.
20
+ *
21
+ * An EMPTY list matches nobody (fail closed): an app that enables the gate but
22
+ * configures no patterns lets no one in, which is the safe default.
23
+ *
24
+ * @module
25
+ */
26
+ /**
27
+ * True when `email` is allowed by ANY pattern in `patterns`. An empty (or
28
+ * missing) list allows nobody - the gate fails closed.
29
+ */
30
+ export declare function matchesAllowlist(email: string, patterns: readonly string[] | undefined): boolean;
31
+ /** Rough shape check so a clearly-invalid address is rejected before any work. */
32
+ export declare function looksLikeEmail(value: string): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unified access allow-list matching for the email-OTP gate.
3
+ *
4
+ * Each pattern in the configured list is one of three shapes:
5
+ *
6
+ * - **domain shortcut** - `example.com` or `@example.com`: matches any
7
+ * address whose domain equals it. This is the gate's OWN semantic (a bare
8
+ * value means "the domain", not "the whole address"), so it is handled here.
9
+ * - **glob** - contains `*` or `?`, e.g. `*@example.com`: matched against the
10
+ * WHOLE address with shell-style wildcards.
11
+ * - **regex** - wrapped in slashes, `/.../ [flags]`: tested against the whole
12
+ * address. An invalid regex never matches (it is skipped with a warning
13
+ * rather than throwing).
14
+ *
15
+ * Only the first shape is this module's business: the glob and regex shapes are
16
+ * delegated to `@dbx-tools/shared-core`'s {@link pattern.toPattern}, which is
17
+ * where that compilation lives for every allow-list in the repo (the tunnel's
18
+ * inbound-header policy uses the same one). Matching is case-insensitive
19
+ * throughout.
20
+ *
21
+ * An EMPTY list matches nobody (fail closed): an app that enables the gate but
22
+ * configures no patterns lets no one in, which is the safe default.
23
+ *
24
+ * @module
25
+ */
26
+ import { pattern } from "@dbx-tools/shared-core";
27
+ /** True when `email` matches a single allow-list `pattern`. */
28
+ function matchesPattern(email, entry) {
29
+ const trimmed = entry.trim();
30
+ if (!trimmed)
31
+ return false;
32
+ const address = email.trim().toLowerCase();
33
+ // A bare value with no wildcard and no regex delimiters is a DOMAIN shortcut,
34
+ // the one shape shared-core cannot infer: there it would mean whole-string
35
+ // equality against the address, which is never what an operator writing
36
+ // `example.com` in an access list intends.
37
+ if (!trimmed.startsWith("/") && !trimmed.includes("*") && !trimmed.includes("?")) {
38
+ const domain = trimmed.replace(/^@/, "").toLowerCase();
39
+ const at = address.lastIndexOf("@");
40
+ return at >= 0 && address.slice(at + 1) === domain;
41
+ }
42
+ return pattern.toPattern(trimmed)?.(address) ?? false;
43
+ }
44
+ /**
45
+ * True when `email` is allowed by ANY pattern in `patterns`. An empty (or
46
+ * missing) list allows nobody - the gate fails closed.
47
+ */
48
+ export function matchesAllowlist(email, patterns) {
49
+ if (!email || !patterns || patterns.length === 0)
50
+ return false;
51
+ return patterns.some((entry) => matchesPattern(email, entry));
52
+ }
53
+ /** Rough shape check so a clearly-invalid address is rejected before any work. */
54
+ export function looksLikeEmail(value) {
55
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
56
+ }
57
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYWxsb3dsaXN0LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FsbG93bGlzdC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBd0JHO0FBRUgsT0FBTyxFQUFFLE9BQU8sRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBRWpELCtEQUErRDtBQUMvRCxTQUFTLGNBQWMsQ0FBQyxLQUFhLEVBQUUsS0FBYTtJQUNsRCxNQUFNLE9BQU8sR0FBRyxLQUFLLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDN0IsSUFBSSxDQUFDLE9BQU87UUFBRSxPQUFPLEtBQUssQ0FBQztJQUMzQixNQUFNLE9BQU8sR0FBRyxLQUFLLENBQUMsSUFBSSxFQUFFLENBQUMsV0FBVyxFQUFFLENBQUM7SUFFM0MsOEVBQThFO0lBQzlFLDJFQUEyRTtJQUMzRSx3RUFBd0U7SUFDeEUsMkNBQTJDO0lBQzNDLElBQUksQ0FBQyxPQUFPLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUNqRixNQUFNLE1BQU0sR0FBRyxPQUFPLENBQUMsT0FBTyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUN2RCxNQUFNLEVBQUUsR0FBRyxPQUFPLENBQUMsV0FBVyxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBQ3BDLE9BQU8sRUFBRSxJQUFJLENBQUMsSUFBSSxPQUFPLENBQUMsS0FBSyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUMsS0FBSyxNQUFNLENBQUM7SUFDckQsQ0FBQztJQUVELE9BQU8sT0FBTyxDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDLE9BQU8sQ0FBQyxJQUFJLEtBQUssQ0FBQztBQUN4RCxDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsTUFBTSxVQUFVLGdCQUFnQixDQUFDLEtBQWEsRUFBRSxRQUF1QztJQUNyRixJQUFJLENBQUMsS0FBSyxJQUFJLENBQUMsUUFBUSxJQUFJLFFBQVEsQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQy9ELE9BQU8sUUFBUSxDQUFDLElBQUksQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsY0FBYyxDQUFDLEtBQUssRUFBRSxLQUFLLENBQUMsQ0FBQyxDQUFDO0FBQ2hFLENBQUM7QUFFRCxrRkFBa0Y7QUFDbEYsTUFBTSxVQUFVLGNBQWMsQ0FBQyxLQUFhO0lBQzFDLE9BQU8sNEJBQTRCLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0FBQ3pELENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvKipcbiAqIFVuaWZpZWQgYWNjZXNzIGFsbG93LWxpc3QgbWF0Y2hpbmcgZm9yIHRoZSBlbWFpbC1PVFAgZ2F0ZS5cbiAqXG4gKiBFYWNoIHBhdHRlcm4gaW4gdGhlIGNvbmZpZ3VyZWQgbGlzdCBpcyBvbmUgb2YgdGhyZWUgc2hhcGVzOlxuICpcbiAqICAgLSAqKmRvbWFpbiBzaG9ydGN1dCoqIC0gYGV4YW1wbGUuY29tYCBvciBgQGV4YW1wbGUuY29tYDogbWF0Y2hlcyBhbnlcbiAqICAgICBhZGRyZXNzIHdob3NlIGRvbWFpbiBlcXVhbHMgaXQuIFRoaXMgaXMgdGhlIGdhdGUncyBPV04gc2VtYW50aWMgKGEgYmFyZVxuICogICAgIHZhbHVlIG1lYW5zIFwidGhlIGRvbWFpblwiLCBub3QgXCJ0aGUgd2hvbGUgYWRkcmVzc1wiKSwgc28gaXQgaXMgaGFuZGxlZCBoZXJlLlxuICogICAtICoqZ2xvYioqIC0gY29udGFpbnMgYCpgIG9yIGA/YCwgZS5nLiBgKkBleGFtcGxlLmNvbWA6IG1hdGNoZWQgYWdhaW5zdCB0aGVcbiAqICAgICBXSE9MRSBhZGRyZXNzIHdpdGggc2hlbGwtc3R5bGUgd2lsZGNhcmRzLlxuICogICAtICoqcmVnZXgqKiAtIHdyYXBwZWQgaW4gc2xhc2hlcywgYC8uLi4vIFtmbGFnc11gOiB0ZXN0ZWQgYWdhaW5zdCB0aGUgd2hvbGVcbiAqICAgICBhZGRyZXNzLiBBbiBpbnZhbGlkIHJlZ2V4IG5ldmVyIG1hdGNoZXMgKGl0IGlzIHNraXBwZWQgd2l0aCBhIHdhcm5pbmdcbiAqICAgICByYXRoZXIgdGhhbiB0aHJvd2luZykuXG4gKlxuICogT25seSB0aGUgZmlyc3Qgc2hhcGUgaXMgdGhpcyBtb2R1bGUncyBidXNpbmVzczogdGhlIGdsb2IgYW5kIHJlZ2V4IHNoYXBlcyBhcmVcbiAqIGRlbGVnYXRlZCB0byBgQGRieC10b29scy9zaGFyZWQtY29yZWAncyB7QGxpbmsgcGF0dGVybi50b1BhdHRlcm59LCB3aGljaCBpc1xuICogd2hlcmUgdGhhdCBjb21waWxhdGlvbiBsaXZlcyBmb3IgZXZlcnkgYWxsb3ctbGlzdCBpbiB0aGUgcmVwbyAodGhlIHR1bm5lbCdzXG4gKiBpbmJvdW5kLWhlYWRlciBwb2xpY3kgdXNlcyB0aGUgc2FtZSBvbmUpLiBNYXRjaGluZyBpcyBjYXNlLWluc2Vuc2l0aXZlXG4gKiB0aHJvdWdob3V0LlxuICpcbiAqIEFuIEVNUFRZIGxpc3QgbWF0Y2hlcyBub2JvZHkgKGZhaWwgY2xvc2VkKTogYW4gYXBwIHRoYXQgZW5hYmxlcyB0aGUgZ2F0ZSBidXRcbiAqIGNvbmZpZ3VyZXMgbm8gcGF0dGVybnMgbGV0cyBubyBvbmUgaW4sIHdoaWNoIGlzIHRoZSBzYWZlIGRlZmF1bHQuXG4gKlxuICogQG1vZHVsZVxuICovXG5cbmltcG9ydCB7IHBhdHRlcm4gfSBmcm9tIFwiQGRieC10b29scy9zaGFyZWQtY29yZVwiO1xuXG4vKiogVHJ1ZSB3aGVuIGBlbWFpbGAgbWF0Y2hlcyBhIHNpbmdsZSBhbGxvdy1saXN0IGBwYXR0ZXJuYC4gKi9cbmZ1bmN0aW9uIG1hdGNoZXNQYXR0ZXJuKGVtYWlsOiBzdHJpbmcsIGVudHJ5OiBzdHJpbmcpOiBib29sZWFuIHtcbiAgY29uc3QgdHJpbW1lZCA9IGVudHJ5LnRyaW0oKTtcbiAgaWYgKCF0cmltbWVkKSByZXR1cm4gZmFsc2U7XG4gIGNvbnN0IGFkZHJlc3MgPSBlbWFpbC50cmltKCkudG9Mb3dlckNhc2UoKTtcblxuICAvLyBBIGJhcmUgdmFsdWUgd2l0aCBubyB3aWxkY2FyZCBhbmQgbm8gcmVnZXggZGVsaW1pdGVycyBpcyBhIERPTUFJTiBzaG9ydGN1dCxcbiAgLy8gdGhlIG9uZSBzaGFwZSBzaGFyZWQtY29yZSBjYW5ub3QgaW5mZXI6IHRoZXJlIGl0IHdvdWxkIG1lYW4gd2hvbGUtc3RyaW5nXG4gIC8vIGVxdWFsaXR5IGFnYWluc3QgdGhlIGFkZHJlc3MsIHdoaWNoIGlzIG5ldmVyIHdoYXQgYW4gb3BlcmF0b3Igd3JpdGluZ1xuICAvLyBgZXhhbXBsZS5jb21gIGluIGFuIGFjY2VzcyBsaXN0IGludGVuZHMuXG4gIGlmICghdHJpbW1lZC5zdGFydHNXaXRoKFwiL1wiKSAmJiAhdHJpbW1lZC5pbmNsdWRlcyhcIipcIikgJiYgIXRyaW1tZWQuaW5jbHVkZXMoXCI/XCIpKSB7XG4gICAgY29uc3QgZG9tYWluID0gdHJpbW1lZC5yZXBsYWNlKC9eQC8sIFwiXCIpLnRvTG93ZXJDYXNlKCk7XG4gICAgY29uc3QgYXQgPSBhZGRyZXNzLmxhc3RJbmRleE9mKFwiQFwiKTtcbiAgICByZXR1cm4gYXQgPj0gMCAmJiBhZGRyZXNzLnNsaWNlKGF0ICsgMSkgPT09IGRvbWFpbjtcbiAgfVxuXG4gIHJldHVybiBwYXR0ZXJuLnRvUGF0dGVybih0cmltbWVkKT8uKGFkZHJlc3MpID8/IGZhbHNlO1xufVxuXG4vKipcbiAqIFRydWUgd2hlbiBgZW1haWxgIGlzIGFsbG93ZWQgYnkgQU5ZIHBhdHRlcm4gaW4gYHBhdHRlcm5zYC4gQW4gZW1wdHkgKG9yXG4gKiBtaXNzaW5nKSBsaXN0IGFsbG93cyBub2JvZHkgLSB0aGUgZ2F0ZSBmYWlscyBjbG9zZWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBtYXRjaGVzQWxsb3dsaXN0KGVtYWlsOiBzdHJpbmcsIHBhdHRlcm5zOiByZWFkb25seSBzdHJpbmdbXSB8IHVuZGVmaW5lZCk6IGJvb2xlYW4ge1xuICBpZiAoIWVtYWlsIHx8ICFwYXR0ZXJucyB8fCBwYXR0ZXJucy5sZW5ndGggPT09IDApIHJldHVybiBmYWxzZTtcbiAgcmV0dXJuIHBhdHRlcm5zLnNvbWUoKGVudHJ5KSA9PiBtYXRjaGVzUGF0dGVybihlbWFpbCwgZW50cnkpKTtcbn1cblxuLyoqIFJvdWdoIHNoYXBlIGNoZWNrIHNvIGEgY2xlYXJseS1pbnZhbGlkIGFkZHJlc3MgaXMgcmVqZWN0ZWQgYmVmb3JlIGFueSB3b3JrLiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGxvb2tzTGlrZUVtYWlsKHZhbHVlOiBzdHJpbmcpOiBib29sZWFuIHtcbiAgcmV0dXJuIC9eW15cXHNAXStAW15cXHNAXStcXC5bXlxcc0BdKyQvLnRlc3QodmFsdWUudHJpbSgpKTtcbn1cbiJdfQ==