@braidlabs/gateway 0.1.1 → 0.2.0
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 +112 -17
- package/package.json +2 -2
- package/src/gateway.d.ts +46 -15
- package/src/gateway.js +260 -44
- package/src/gateway.js.map +1 -1
- package/src/protocol.d.ts +18 -2
- package/src/protocol.js +19 -3
- package/src/protocol.js.map +1 -1
- package/src/redirect.d.ts +70 -0
- package/src/redirect.js +172 -0
- package/src/redirect.js.map +1 -0
- package/src/registry.d.ts +73 -0
- package/src/registry.js +16 -0
- package/src/registry.js.map +1 -1
- package/src/rewriter/html-rewrite-stream.d.ts +8 -3
- package/src/rewriter/html-rewrite-stream.js +25 -7
- package/src/rewriter/html-rewrite-stream.js.map +1 -1
- package/src/rewriter/transforms.d.ts +60 -10
- package/src/rewriter/transforms.js +192 -49
- package/src/rewriter/transforms.js.map +1 -1
- package/src/single-flight.d.ts +7 -1
- package/src/single-flight.js +26 -6
- package/src/single-flight.js.map +1 -1
package/README.md
CHANGED
|
@@ -141,7 +141,10 @@ runs at most once per request.
|
|
|
141
141
|
request inside the fragment's namespace. Version mismatches fail in the client as named errors
|
|
142
142
|
— no title-check heuristics.
|
|
143
143
|
- **Fragment documents** (`/__braid/doc/:id/*`): the fragment's HTML prepared for the host page's
|
|
144
|
-
DOM — exactly what piercing injects, for the client-boot path.
|
|
144
|
+
DOM — exactly what piercing injects, for the client-boot path. Fetched at the page's route,
|
|
145
|
+
unless the manifest declares `documentPath` (`/index.html`) for an SPA on a static origin that
|
|
146
|
+
404s every route. A fragment's redirect here is reported as an error, or — for a fragment that
|
|
147
|
+
opts in — followed as a page navigation; see [Login redirects](#login-redirects-opt-in).
|
|
145
148
|
- **Fragment assets/data** (`/__braid/frag/:id/*`): forwarded to the endpoint with the prefix
|
|
146
149
|
stripped, so endpoints see the same paths they serve standalone. Redirects pass through
|
|
147
150
|
unfollowed; each fragment gets a manifest-declared timeout budget.
|
|
@@ -166,7 +169,10 @@ one created before `</body>`.
|
|
|
166
169
|
Fragment HTML is transformed on the way through: the doctype is stripped, `<html>/<head>/<body>`
|
|
167
170
|
become `braid-html/braid-head/braid-body` (start *and* end tags), scripts are neutralized to
|
|
168
171
|
`type="inert"`, and script preload links become `rel="inert-*"`. Fragment scripts are therefore
|
|
169
|
-
never live in the host realm, not even between parsing and activation.
|
|
172
|
+
never live in the host realm, not even between parsing and activation. The gateway does add
|
|
173
|
+
`<link rel="prefetch">` hints for the fragment's own scripts (inside its shadow root, never inside
|
|
174
|
+
its document) so their downloads start before the client boots it; a prefetch fetches into the
|
|
175
|
+
HTTP cache and never executes anything.
|
|
170
176
|
|
|
171
177
|
If a fragment can't be server-rendered, the page still renders and the slot is left for the
|
|
172
178
|
client runtime to fill — a transient SSR failure self-heals rather than becoming a visible
|
|
@@ -188,6 +194,20 @@ browser, and most CDNs ignore `Vary` on anything but `Accept-Encoding`.
|
|
|
188
194
|
**No braid URL varies on a request header.** Point a CDN at them and they cache correctly with
|
|
189
195
|
no configuration at all.
|
|
190
196
|
|
|
197
|
+
### Mounting under a path
|
|
198
|
+
|
|
199
|
+
A host that shares its domain and does not own the root — `/manage/` behind a load balancer —
|
|
200
|
+
moves the namespaces under its mount. Set the same `basePath` on both sides:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
createGateway({ registry, basePath: '/manage' }); // serves /manage/__braid/{frag,realm,doc}/…
|
|
204
|
+
initBraid({ basePath: '/manage' }); // host page: requests them there
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The gateway then leaves root `/__braid/*` alone. Page URLs are unaffected: `pierce` patterns still
|
|
208
|
+
match the full pathname (`/manage/goals/*`). `serviceWorker`, `discovery`, and
|
|
209
|
+
`telemetry.webVitals` do not support a `basePath` yet, and combining them with one throws.
|
|
210
|
+
|
|
191
211
|
### The one thing that does vary
|
|
192
212
|
|
|
193
213
|
| Header | URL | Response changes to | Why |
|
|
@@ -211,6 +231,58 @@ advisory, and many CDNs honor it only for `Accept-Encoding`. Since only *page* U
|
|
|
211
231
|
and page URLs are usually personalized and uncacheable anyway, the practical advice is simply:
|
|
212
232
|
don't edge-cache pierced pages. See [CDN setup](../../docs/braid-cdn.md).
|
|
213
233
|
|
|
234
|
+
## Login redirects (opt-in)
|
|
235
|
+
|
|
236
|
+
A fragment that needs a login answers its document request with a redirect. A browser fetch cannot
|
|
237
|
+
act on that: followed, it dies as a CORS error; told not to follow, it hides where the redirect was
|
|
238
|
+
going. So by default the client reports a named `fragment-fetch` error, and the redirect goes nowhere.
|
|
239
|
+
|
|
240
|
+
A fragment can opt in to having the page follow it instead:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
createGateway({
|
|
244
|
+
registry: [{ id: 'goals', endpoint: 'https://goals.internal/', redirect: 'navigate' }],
|
|
245
|
+
redirectOrigins: ['https://login.example.com', 'https://*.example.com'],
|
|
246
|
+
additionalHeaders: (request) => ({ 'x-user': signedIdentityFor(request) }), // see below
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For that fragment's `/__braid/doc/` requests the gateway answers a redirect with a `409` carrying the
|
|
251
|
+
target in `x-braid-redirect-location` (never a 3xx, which the browser would hide again), and the
|
|
252
|
+
client navigates the whole page there — what the shell would have done had the redirect hit the
|
|
253
|
+
top-level request. Nothing else changes: other fragments, and the `/__braid/frag/` namespace, keep
|
|
254
|
+
passing redirects through.
|
|
255
|
+
|
|
256
|
+
**Where it may go.** The target is chosen by the fragment's endpoint and becomes a top-level
|
|
257
|
+
navigation, so it is checked, and anything else is refused with a `502`:
|
|
258
|
+
|
|
259
|
+
| Target | Result |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| A path on the host's own origin (including `/__braid/…`, for a login that is itself a fragment) | followed as a host path |
|
|
262
|
+
| A path inside the fragment's own endpoint (`/apps/goals/login` for `https://goals.internal/apps/goals/`) | mapped to the same path on the host (`/login`) |
|
|
263
|
+
| An origin in `redirectOrigins` — exact (`https://login.example.com`) or a subdomain wildcard (`https://*.example.com`, which does not match `example.com` itself) | followed as given |
|
|
264
|
+
| `javascript:`, `data:`, any other scheme, any other origin | refused |
|
|
265
|
+
|
|
266
|
+
**Return URLs.** A fragment builds `?next=` from the URL it saw, which is its own and means nothing to
|
|
267
|
+
the user. A query parameter whose whole value is that URL (its path, on the fragment's origin or
|
|
268
|
+
the host's; the query is ignored) is pointed at the page the user was on, taken from the
|
|
269
|
+
`x-braid-return-url` header the client sends (the `Referer` header is the fallback, and only a page on
|
|
270
|
+
the gateway's origin is ever used). The header is read by the gateway and not forwarded to the
|
|
271
|
+
fragment, because a page URL can carry a token in its hash. Other parameters keep their exact bytes. Not
|
|
272
|
+
rewritten: a return URL buried inside another parameter's value (an OAuth `state`, say), and a
|
|
273
|
+
parameter equal to the fragment's bare root `/`, which is usually an OAuth `redirect_uri`.
|
|
274
|
+
|
|
275
|
+
**Concurrent requests are not coalesced** for a fragment that opts in: a redirect's cookies carry the
|
|
276
|
+
login's state and nonce, and sharing one with another caller would share those too.
|
|
277
|
+
|
|
278
|
+
**The loop.** The gateway strips the caller's cookies by default, so a fragment that redirects
|
|
279
|
+
because it cannot see who the user is will redirect again after login sends them back — forever. The
|
|
280
|
+
client follows a given fragment's redirect at most once per 30 seconds (remembered in
|
|
281
|
+
`sessionStorage`; if that is unavailable it does not follow at all) and reports an error instead.
|
|
282
|
+
It only works end to end once the fragment can see the user: `forwardCredentials`, or a per-caller
|
|
283
|
+
header via `additionalHeaders`. The gateway warns when a fragment opts in and neither is set — at startup for an inline registry, on the
|
|
284
|
+
first redirect for a URL or loader registry.
|
|
285
|
+
|
|
214
286
|
## Discovery endpoint (optional)
|
|
215
287
|
|
|
216
288
|
For shells that build their UI from the registry rather than hard-coding slot names — a launcher,
|
|
@@ -406,11 +478,16 @@ wants; nothing outlives the request that started it. No TTL, no invalidation, no
|
|
|
406
478
|
no dependence on which instance a request lands on — which is what makes it safe by default on ECS
|
|
407
479
|
or any horizontally scaled deployment.
|
|
408
480
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
481
|
+
The key is computed from the request that is actually **sent**, so it contains exactly what the
|
|
482
|
+
endpoint can see: `user-agent`, the negotiation headers, any header the gateway itself added via
|
|
483
|
+
`additionalHeaders`, and — when `forwardCredentials` is on — `cookie` and `authorization`. A
|
|
484
|
+
fragment declaring `access` rules is never shared at all.
|
|
485
|
+
|
|
486
|
+
Because credentials are not forwarded by default, two signed-in users produce a byte-identical
|
|
487
|
+
fragment request and *do* share a flight, which is correct: the endpoint cannot tell them apart.
|
|
488
|
+
Turn on `forwardCredentials`, or add a per-caller header, and they stop sharing again. Sharing a
|
|
489
|
+
render across identities the endpoint *can* distinguish is a data leak wearing a performance
|
|
490
|
+
feature's clothes, so anything that varies by caller has to be in the key.
|
|
414
491
|
|
|
415
492
|
For an endpoint that varies on something the gateway cannot see — a tenant or feature-flag header —
|
|
416
493
|
opt out on the manifest with `coalesce: false`.
|
|
@@ -438,20 +515,38 @@ gets unstamped markup, which is correct for all three.
|
|
|
438
515
|
|
|
439
516
|
The trusted tier is **namespace isolation, not a security boundary**: fragments are same-origin
|
|
440
517
|
with the host and share its cookies, storage, and DOM reachability. What the gateway does
|
|
441
|
-
guarantee is that
|
|
442
|
-
navigate the host page —
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
`
|
|
448
|
-
|
|
518
|
+
guarantee is that no code a fragment's markup carries inline can execute outside the fragment's
|
|
519
|
+
realm or navigate the host page — with one opt-in exception, [login redirects](#login-redirects-opt-in),
|
|
520
|
+
whose targets are restricted to the host's origin, the fragment's own endpoint, and
|
|
521
|
+
`redirectOrigins` — scripts are neutralized, inline `on*` handlers are stripped,
|
|
522
|
+
`<meta http-equiv="refresh">` is defanged, and frames whose document the markup carries inline
|
|
523
|
+
lose it: `iframe[srcdoc]`, and any `iframe`/`embed`/`object` URL other than `http(s):`, a path, or
|
|
524
|
+
`about:blank` (so `javascript:`, `data:`, `blob:`). `srcdoc` and `javascript:` run on parse with
|
|
525
|
+
the host's origin, no click needed; `data:` and `blob:` are refused alongside them as the same
|
|
526
|
+
markup-as-document shape. Each removal is named on the tag in `data-braid-blocked`. The rule is
|
|
527
|
+
deliberately blunt — it also removes a sandboxed `srcdoc` and a `data:` PDF or SVG in an
|
|
528
|
+
`object` — so serve such content from a URL instead. A frame that loads a document from the
|
|
529
|
+
fragment's own namespace (`<iframe src="widget.html">`) is left alone: it runs the fragment's
|
|
530
|
+
code with the host's origin, which its realm already does. The full list, the trade-offs and the
|
|
531
|
+
workarounds: [What the gateway does to fragment markup](../../docs/braid-fragment-markup.md).
|
|
532
|
+
|
|
533
|
+
Deliberately not neutralized, because they require a user to click rather than executing on
|
|
534
|
+
parse: `javascript:` URLs on links (`a`, `area`) and form `action`s. A trusted fragment can
|
|
535
|
+
navigate a page the user clicks through.
|
|
449
536
|
|
|
450
537
|
Defaults worth knowing: `x-forwarded-proto`/`x-forwarded-host` are **overwritten** from the real
|
|
451
538
|
request (opt into passthrough with `trustForwardedHeaders` only behind a proxy you control); an
|
|
452
539
|
endpoint's path is a boundary, so `endpoint: 'https://internal/apps/billing/'` cannot be used to
|
|
453
|
-
reach the rest of that origin; and
|
|
454
|
-
fragment endpoints
|
|
540
|
+
reach the rest of that origin; and the caller's `Cookie` and `Authorization` are **not** forwarded
|
|
541
|
+
to fragment endpoints.
|
|
542
|
+
|
|
543
|
+
That last one is the trust boundary between a shell and the fragments it composes. Those headers
|
|
544
|
+
authenticate the caller to *the shell's* origin, and a fragment endpoint is frequently a different
|
|
545
|
+
team and often a different company — forwarding them would let every fragment backend act as that
|
|
546
|
+
user against the shell. A fragment that needs to know who is asking should be given something
|
|
547
|
+
scoped to it: a per-request assertion via `additionalHeaders`, or a token exchanged for the
|
|
548
|
+
fragment's own audience. `forwardCredentials: true` restores the old behaviour, and is only
|
|
549
|
+
appropriate when every endpoint in the registry sits inside the same trust boundary as the shell.
|
|
455
550
|
|
|
456
551
|
Not yet implemented from the architecture's security section: allow-listed manifest origins and
|
|
457
552
|
signed manifests. Until those land, treat the registry as trusted configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@braidlabs/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Braid gateway — fetch-native origin-front middleware that routes fragment traffic by exact id under the reserved /__braid/frag/ namespace, loads fragment manifests, and passes everything else through to your existing app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"micro-frontends",
|
|
@@ -41,4 +41,4 @@
|
|
|
41
41
|
"url": "https://github.com/braidjs/braid/issues"
|
|
42
42
|
},
|
|
43
43
|
"module": "./src/index.js"
|
|
44
|
-
}
|
|
44
|
+
}
|
package/src/gateway.d.ts
CHANGED
|
@@ -2,26 +2,34 @@ import { Principal, RegistrySource } from './registry.js';
|
|
|
2
2
|
import { DiscoveryOptions } from './discovery.js';
|
|
3
3
|
import { type BreakerOptions } from './breaker.js';
|
|
4
4
|
import { type TelemetryOptions } from './telemetry.js';
|
|
5
|
-
/**
|
|
6
|
-
* Gateway core: fetch-native, platform-neutral origin-front middleware.
|
|
7
|
-
*
|
|
8
|
-
* Two responsibilities:
|
|
9
|
-
*
|
|
10
|
-
* 1. **Namespace routing**: requests under `/__braid/frag/:id/*` address a fragment by id,
|
|
11
|
-
* exactly — realm stubs, assets, and data. No pattern matching, no request sniffing.
|
|
12
|
-
* 2. **Piercing**: for document requests whose page URL a fragment declares in its
|
|
13
|
-
* `pierce` patterns, the fragment's server-rendered HTML is interleaved into the shell's
|
|
14
|
-
* response stream, so fragments paint with the shell's first response.
|
|
15
|
-
*
|
|
16
|
-
* Everything else passes through to the shell untouched.
|
|
17
|
-
*/
|
|
18
5
|
export interface GatewayOptions {
|
|
19
6
|
/** The fragment registry: inline manifests, a JSON URL, or an async loader. */
|
|
20
7
|
registry: RegistrySource;
|
|
21
8
|
/** 'development' enables verbose error bodies; defaults to 'development'. */
|
|
22
9
|
mode?: 'production' | 'development';
|
|
23
|
-
/**
|
|
24
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Additional headers set on every request forwarded to a fragment endpoint.
|
|
12
|
+
*
|
|
13
|
+
* A function is called once per fragment request with the *incoming* request, for headers that
|
|
14
|
+
* describe the current caller — a signed identity assertion, a trace id, a tenant id. A plain
|
|
15
|
+
* object is read once at construction, so anything per-request has to be the function form.
|
|
16
|
+
*/
|
|
17
|
+
additionalHeaders?: Record<string, string> | ((request: Request) => Record<string, string>);
|
|
18
|
+
/**
|
|
19
|
+
* Forward the caller's `Cookie` and `Authorization` headers to fragment endpoints.
|
|
20
|
+
*
|
|
21
|
+
* **Off by default, deliberately.** A fragment endpoint is a URL from the registry — frequently
|
|
22
|
+
* a different team, often a different company, sometimes a different network. `Cookie` and
|
|
23
|
+
* `Authorization` authenticate the caller to *the shell's* origin, so forwarding them hands
|
|
24
|
+
* every fragment backend the ability to act as that user against it. That is the whole trust
|
|
25
|
+
* boundary between a host and the fragments it composes.
|
|
26
|
+
*
|
|
27
|
+
* A fragment that needs to identify the caller should be given something scoped to it: an
|
|
28
|
+
* assertion minted per request via {@link GatewayOptions.additionalHeaders}, or a token
|
|
29
|
+
* exchanged for the fragment's own audience. Turn this on only when every endpoint in the
|
|
30
|
+
* registry is inside the same trust boundary as the shell.
|
|
31
|
+
*/
|
|
32
|
+
forwardCredentials?: boolean;
|
|
25
33
|
/**
|
|
26
34
|
* Publishes a paginated listing of the fragments this gateway serves, for shells that build
|
|
27
35
|
* their UI from the registry instead of hard-coding slot names.
|
|
@@ -144,6 +152,29 @@ export interface GatewayOptions {
|
|
|
144
152
|
* on an origin it does not own.
|
|
145
153
|
*/
|
|
146
154
|
serviceWorker?: boolean | ServiceWorkerOptions;
|
|
155
|
+
/**
|
|
156
|
+
* The path this gateway is mounted under, for a host that shares its domain and does not own the
|
|
157
|
+
* root (`/manage`). The reserved namespaces move under it — `/manage/__braid/frag/…` — and the
|
|
158
|
+
* gateway stops answering them at the root. Pass the same value to `initBraid({ basePath })` in
|
|
159
|
+
* the host page.
|
|
160
|
+
*
|
|
161
|
+
* Only the namespaces move. Page URLs are untouched: `pierce` patterns still match the full
|
|
162
|
+
* pathname, and bound fragments are still fetched at it.
|
|
163
|
+
*
|
|
164
|
+
* Not yet supported together with `serviceWorker`, `discovery`, or `telemetry.webVitals`, which
|
|
165
|
+
* would otherwise keep serving at the root; combining them throws.
|
|
166
|
+
*/
|
|
167
|
+
basePath?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Origins a fragment with `redirect: 'navigate'` may send the page to, beyond the host's own
|
|
170
|
+
* origin and the fragment's own endpoint path — typically an identity provider.
|
|
171
|
+
*
|
|
172
|
+
* Exact origins (`https://login.example.com`) or a subdomain wildcard (`https://*.example.com`,
|
|
173
|
+
* which matches `a.example.com` and `a.b.example.com` but not `example.com` itself). A redirect to
|
|
174
|
+
* anywhere else is refused with a 502 rather than followed: it ends up as a top-level navigation,
|
|
175
|
+
* and the fragment's endpoint should not be able to choose where the whole page goes.
|
|
176
|
+
*/
|
|
177
|
+
redirectOrigins?: string[];
|
|
147
178
|
}
|
|
148
179
|
export interface ServiceWorkerOptions {
|
|
149
180
|
/** The scope to claim, sent as `Service-Worker-Allowed`. Defaults to `/`. */
|