@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 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
- Two requests share a fetch only when their `cookie`, `authorization`, `user-agent`, and negotiation
410
- headers match exactly, and a fragment declaring `access` rules is never shared at all. So this helps
411
- anonymous and shared-identity traffic and does nothing for personalized fragments — two signed-in
412
- users never share a flight. That is correct rather than a limitation: sharing a render across
413
- identities is a data leak wearing a performance feature's clothes.
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 nothing a fragment sends can execute JavaScript in the *host realm* or
442
- navigate the host page — scripts are neutralized, inline `on*` handlers are stripped, and
443
- `<meta http-equiv="refresh">` is defanged. Fragment code runs in the fragment's realm or not at
444
- all.
445
-
446
- Deliberately not neutralized, because they require a user to act rather than executing on parse:
447
- `javascript:` URLs and form `action`s. A trusted fragment can navigate a page the user clicks
448
- through.
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 every request header — including `Cookie` — is forwarded to
454
- fragment endpoints, so treat a manifest entry as granting that endpoint the user's session.
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.1.1",
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
- /** Additional headers set on every request forwarded to a fragment endpoint. */
24
- additionalHeaders?: Record<string, string>;
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 `/`. */