@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 +373 -0
- package/index.ts +28 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +21 -0
- package/lib/src/allowlist.d.ts +32 -0
- package/lib/src/allowlist.js +57 -0
- package/lib/src/app.d.ts +130 -0
- package/lib/src/app.js +258 -0
- package/lib/src/env.d.ts +57 -0
- package/lib/src/env.js +60 -0
- package/lib/src/headers.d.ts +108 -0
- package/lib/src/headers.js +140 -0
- package/lib/src/interceptor.d.ts +56 -0
- package/lib/src/interceptor.js +84 -0
- package/lib/src/otp.d.ts +49 -0
- package/lib/src/otp.js +124 -0
- package/lib/src/plugin.d.ts +147 -0
- package/lib/src/plugin.js +138 -0
- package/lib/src/portr.d.ts +40 -0
- package/lib/src/portr.js +93 -0
- package/lib/src/proxy.d.ts +49 -0
- package/lib/src/proxy.js +247 -0
- package/lib/src/rate-limit.d.ts +35 -0
- package/lib/src/rate-limit.js +53 -0
- package/lib/src/signing-key.d.ts +86 -0
- package/lib/src/signing-key.js +170 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +70 -0
- package/src/allowlist.ts +60 -0
- package/src/app.ts +292 -0
- package/src/env.ts +72 -0
- package/src/headers.ts +155 -0
- package/src/interceptor.ts +105 -0
- package/src/otp.ts +137 -0
- package/src/plugin.ts +269 -0
- package/src/portr.ts +113 -0
- package/src/proxy.ts +299 -0
- package/src/rate-limit.ts +59 -0
- package/src/signing-key.ts +201 -0
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==
|