@braidlabs/gateway 0.6.0 → 0.7.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
@@ -173,6 +173,12 @@ as a declarative shadow root, so the browser parses it into exactly the shape th
173
173
  would have built; the slot then adopts it instead of fetching. Shells with no matching slot get
174
174
  one created before `</body>`.
175
175
 
176
+ The stream waits at a slot whose fragment has not answered yet, so a slow fragment holds back the
177
+ markup after it, for up to its `timeoutMs`. Set `stream: 'out-of-order'` on its manifest to stream
178
+ past the slot instead: the slot is marked `data-braid-deferred`, and the fragment's markup follows
179
+ before `</body>` in a hidden `<braid-late>` that the client moves into it. A fragment that answered
180
+ in time is inlined as usual. See the [gateway docs](../../docs/packages/gateway.md#streaming-past-a-slow-fragment).
181
+
176
182
  A shell can instead mark one `<fragment-slot outlet>`. It takes the page's routed (bound) fragment,
177
183
  the first in registration order that no earlier named slot claimed, and is named after it. A shell
178
184
  that composes many routes then needs one slot rather than one per fragment id. Unbound widgets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braidlabs/gateway",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -0,0 +1,34 @@
1
+ /**
2
+ * W3C `baggage` for fragment requests, safely.
3
+ *
4
+ * Baggage is arbitrary key/value context that rides alongside a trace — a tenant, an experiment,
5
+ * a feature flag — and that is the problem with a browser's: a header any page script or extension
6
+ * can set, flowing into every internal service behind every fragment. `new Request(url, request)`
7
+ * used to copy it verbatim. Now a fragment request carries baggage only as the deployment allows:
8
+ *
9
+ * - inbound members are dropped unless their key is allowlisted, their value is well formed (W3C
10
+ * baggage octets, valid percent-encoding), and, when the allowlist gives a pattern, their decoded
11
+ * value matches it in full; member properties are dropped;
12
+ * - members the gateway sets itself win over a client's with the same key;
13
+ * - anything over the W3C limits (8192 bytes, 180 members) is dropped whole, as is a header that
14
+ * cannot be parsed;
15
+ * - and only fragments inside the deployment's trust boundary get any, by the same rule as
16
+ * `forwardCredentials` — decided by the caller.
17
+ */
18
+ export interface BaggageOptions {
19
+ /**
20
+ * Inbound members a fragment may receive: keys, or keys mapped to a pattern their decoded value
21
+ * must match in full. Absent: none.
22
+ */
23
+ forward?: readonly string[] | Readonly<Record<string, RegExp>>;
24
+ /**
25
+ * Members the gateway adds to the request for one fragment, overriding a client's with the same
26
+ * key. Values are percent-encoded for you.
27
+ */
28
+ set?: (request: Request, fragmentId: string) => Record<string, string>;
29
+ }
30
+ /**
31
+ * The `baggage` header a fragment request carries, or `null` for none. The caller decides whether
32
+ * the fragment is inside the trust boundary at all; call this only for one that is.
33
+ */
34
+ export declare function fragmentBaggage(request: Request, options: BaggageOptions | undefined, fragmentId: string): string | null;
package/src/baggage.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * W3C `baggage` for fragment requests, safely.
3
+ *
4
+ * Baggage is arbitrary key/value context that rides alongside a trace — a tenant, an experiment,
5
+ * a feature flag — and that is the problem with a browser's: a header any page script or extension
6
+ * can set, flowing into every internal service behind every fragment. `new Request(url, request)`
7
+ * used to copy it verbatim. Now a fragment request carries baggage only as the deployment allows:
8
+ *
9
+ * - inbound members are dropped unless their key is allowlisted, their value is well formed (W3C
10
+ * baggage octets, valid percent-encoding), and, when the allowlist gives a pattern, their decoded
11
+ * value matches it in full; member properties are dropped;
12
+ * - members the gateway sets itself win over a client's with the same key;
13
+ * - anything over the W3C limits (8192 bytes, 180 members) is dropped whole, as is a header that
14
+ * cannot be parsed;
15
+ * - and only fragments inside the deployment's trust boundary get any, by the same rule as
16
+ * `forwardCredentials` — decided by the caller.
17
+ */
18
+ const MAX_BYTES = 8192;
19
+ const MAX_MEMBERS = 180;
20
+ /** RFC 7230 token, which is what a baggage key is. */
21
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
22
+ /** A value as it may appear on the wire: W3C baggage-octets, anything else percent-encoded. */
23
+ const VALUE = /^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*$/;
24
+ const encoder = new TextEncoder();
25
+ /**
26
+ * Each pattern, anchored around its whole source — an unanchored `/eu|us/` must not pass `euw`, nor
27
+ * `/a|ab/` fail `ab` — and without the flags that make a RegExp stateful (`g`, `y`) or let `^`/`$`
28
+ * match inside a value (`m`).
29
+ */
30
+ const anchored = new WeakMap();
31
+ function matchesWhole(pattern, value) {
32
+ let whole = anchored.get(pattern);
33
+ if (!whole) {
34
+ whole = new RegExp(`^(?:${pattern.source})$`, pattern.flags.replace(/[gmy]/g, ''));
35
+ anchored.set(pattern, whole);
36
+ }
37
+ return whole.test(value);
38
+ }
39
+ function allowedValue(forward, key, rawValue) {
40
+ if (!forward || !VALUE.test(rawValue))
41
+ return false;
42
+ let value;
43
+ try {
44
+ value = decodeURIComponent(rawValue);
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ if (Array.isArray(forward))
50
+ return forward.includes(key);
51
+ const patterns = forward;
52
+ return Object.hasOwn(patterns, key) && matchesWhole(patterns[key], value);
53
+ }
54
+ /**
55
+ * The `baggage` header a fragment request carries, or `null` for none. The caller decides whether
56
+ * the fragment is inside the trust boundary at all; call this only for one that is.
57
+ */
58
+ export function fragmentBaggage(request, options, fragmentId) {
59
+ const members = new Map();
60
+ const inbound = request.headers.get('baggage');
61
+ if (inbound && options?.forward && encoder.encode(inbound).length <= MAX_BYTES) {
62
+ for (const part of inbound.split(',')) {
63
+ const member = part.trim();
64
+ if (!member)
65
+ continue;
66
+ // properties (`;prop=…`) are dropped: nothing downstream asked for them
67
+ const [pair] = member.split(';');
68
+ const separator = pair.indexOf('=');
69
+ if (separator <= 0)
70
+ continue;
71
+ const key = pair.slice(0, separator).trim();
72
+ const value = pair.slice(separator + 1).trim();
73
+ if (!TOKEN.test(key) || !allowedValue(options.forward, key, value))
74
+ continue;
75
+ members.set(key, value);
76
+ }
77
+ }
78
+ for (const [key, value] of Object.entries(options?.set?.(request, fragmentId) ?? {})) {
79
+ if (!TOKEN.test(key))
80
+ continue;
81
+ try {
82
+ members.set(key, encodeURIComponent(value));
83
+ }
84
+ catch {
85
+ // a lone surrogate has no encoding; the member is dropped rather than failing the request
86
+ }
87
+ }
88
+ if (members.size === 0 || members.size > MAX_MEMBERS)
89
+ return null;
90
+ const header = [...members].map(([key, value]) => `${key}=${value}`).join(',');
91
+ return encoder.encode(header).length <= MAX_BYTES ? header : null;
92
+ }
93
+ //# sourceMappingURL=baggage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baggage.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/baggage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ import type { FragmentManifest } from './registry.js';
2
+ /**
3
+ * The partition a fragment's cookies and storage live in, or `null` for the shared origin.
4
+ *
5
+ * Compat fragments only, as on the client: a custom-element or contract fragment runs in the host
6
+ * window, where nothing would show it its cookies under their own names again.
7
+ */
8
+ export declare function storagePartitionOf(fragment: Pick<FragmentManifest, 'id' | 'capabilities' | 'adapter'>): string | null;
9
+ /**
10
+ * `braid$<partition>$`, with the partition percent-encoded so it is a valid cookie-name token
11
+ * (RFC 6265) and can never contain the `$` that ends it.
12
+ */
13
+ export declare function cookieScopePrefix(partition: string): string;
14
+ /** Whether a cookie name is in any fragment's scope. Nothing legitimate starts with the mark. */
15
+ export declare function isScopedCookieName(name: string): boolean;
16
+ /**
17
+ * One `Set-Cookie` line, moved into the partition's scope.
18
+ *
19
+ * The name gains the scope; `Domain` is dropped, so the cookie is host-only; `Path` becomes `/`,
20
+ * so it still arrives on page requests and a pierced fragment can be rendered signed-in. Every
21
+ * other attribute is kept. A name that already looks scoped is scoped again, so an endpoint can
22
+ * never set a cookie in another fragment's scope.
23
+ */
24
+ export declare function scopeSetCookie(line: string, partition: string): string;
25
+ /**
26
+ * The `Cookie` header a partitioned fragment's endpoint receives: its own cookies under the names
27
+ * its endpoint gave them, plus — only when `includeHost` (the deployment's `forwardCredentials`
28
+ * rule) — the host's unscoped cookies. Never another fragment's. `null` when nothing is left.
29
+ */
30
+ export declare function scopeRequestCookies(header: string | null, partition: string, includeHost: boolean): string | null;
31
+ /**
32
+ * The caller's cookies for a *shared* fragment under `forwardCredentials`: everything but the
33
+ * partitioned fragments' scoped cookies, which belong to their own fragments alone.
34
+ */
35
+ export declare function withoutScopedCookies(header: string | null): string | null;
36
+ /**
37
+ * Rewrites every `Set-Cookie` on `headers` into the partition's scope, in place, and drops
38
+ * `Clear-Site-Data`, with which one fragment's logout would wipe every other fragment's cookies
39
+ * and storage along with its own.
40
+ */
41
+ export declare function scopeResponseCookies(headers: Headers, partition: string): void;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Per-fragment cookies for partitioned fragments: the server half of `capabilities.storage`.
3
+ *
4
+ * Trusted fragments share the host origin, and so its cookie jar. When fragments' backends finish
5
+ * an authorization-code login by setting a session cookie, two of them setting `session` with
6
+ * `Path=/` overwrite each other — and a gateway that forwards the caller's cookies hands every
7
+ * fragment every other fragment's session. For a partitioned fragment the gateway renames the
8
+ * cookies its endpoint sets into the partition's scope, and sends it only its own.
9
+ *
10
+ * Set-Cookie: session=… → Set-Cookie: braid$orders$session=…; Path=/
11
+ * Cookie: braid$orders$session=a; braid$billing$session=b; theme=dark
12
+ * → Cookie: session=a (to orders)
13
+ *
14
+ * The partition is the one `capabilities.storage` names, so fragments that share Web Storage
15
+ * share cookies too. Fragments that do not opt in are untouched.
16
+ *
17
+ * The encoding is duplicated in `@braidlabs/core` (`compat/cookie-partition.ts`), which gives a
18
+ * partitioned realm the same view through `document.cookie`; e2e/cookie-scope.e2e.ts holds the two
19
+ * to each other.
20
+ */
21
+ const SCOPE_MARK = 'braid$';
22
+ /** Cookie-name prefixes the browser enforces, which must stay at the front of the name. */
23
+ const BROWSER_PREFIXES = ['__Host-', '__Secure-'];
24
+ /**
25
+ * The partition a fragment's cookies and storage live in, or `null` for the shared origin.
26
+ *
27
+ * Compat fragments only, as on the client: a custom-element or contract fragment runs in the host
28
+ * window, where nothing would show it its cookies under their own names again.
29
+ */
30
+ export function storagePartitionOf(fragment) {
31
+ if ((fragment.adapter ?? 'compat') !== 'compat')
32
+ return null;
33
+ const grant = fragment.capabilities?.storage;
34
+ if (grant === 'partitioned')
35
+ return fragment.id;
36
+ if (grant && typeof grant === 'object') {
37
+ const partition = grant.partition;
38
+ if (typeof partition === 'string' && partition !== '')
39
+ return partition;
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * `braid$<partition>$`, with the partition percent-encoded so it is a valid cookie-name token
45
+ * (RFC 6265) and can never contain the `$` that ends it.
46
+ */
47
+ export function cookieScopePrefix(partition) {
48
+ const encoded = encodeURIComponent(partition).replace(/[()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
49
+ return `${SCOPE_MARK}${encoded}$`;
50
+ }
51
+ /** Splits off a `__Host-`/`__Secure-` prefix, matched without regard to case as browsers do. */
52
+ function splitBrowserPrefix(name) {
53
+ const prefix = BROWSER_PREFIXES.find((candidate) => name.toLowerCase().startsWith(candidate.toLowerCase()));
54
+ return prefix ? [name.slice(0, prefix.length), name.slice(prefix.length)] : ['', name];
55
+ }
56
+ /** Whether a cookie name is in any fragment's scope. Nothing legitimate starts with the mark. */
57
+ export function isScopedCookieName(name) {
58
+ return splitBrowserPrefix(name.trim())[1].startsWith(SCOPE_MARK);
59
+ }
60
+ function parseCookieHeader(header) {
61
+ return header
62
+ .split(';')
63
+ .map((part) => part.trim())
64
+ .filter(Boolean)
65
+ .map((cookie) => {
66
+ const separator = cookie.indexOf('=');
67
+ return { name: separator === -1 ? cookie : cookie.slice(0, separator), cookie };
68
+ });
69
+ }
70
+ /**
71
+ * One `Set-Cookie` line, moved into the partition's scope.
72
+ *
73
+ * The name gains the scope; `Domain` is dropped, so the cookie is host-only; `Path` becomes `/`,
74
+ * so it still arrives on page requests and a pierced fragment can be rendered signed-in. Every
75
+ * other attribute is kept. A name that already looks scoped is scoped again, so an endpoint can
76
+ * never set a cookie in another fragment's scope.
77
+ */
78
+ export function scopeSetCookie(line, partition) {
79
+ const [pair, ...attributes] = line.split(';');
80
+ const separator = pair.indexOf('=');
81
+ // a cookie without a name (`=value`, or no `=`) is unusual enough to drop rather than guess at
82
+ if (separator <= 0)
83
+ return '';
84
+ const [browserPrefix, name] = splitBrowserPrefix(pair.slice(0, separator).trim());
85
+ const value = pair.slice(separator + 1);
86
+ const kept = attributes
87
+ .map((attribute) => attribute.trim())
88
+ .filter((attribute) => attribute !== '' && !/^(domain|path)\s*(=|$)/i.test(attribute));
89
+ return [`${browserPrefix}${cookieScopePrefix(partition)}${name}=${value}`, 'Path=/', ...kept].join('; ');
90
+ }
91
+ /**
92
+ * The `Cookie` header a partitioned fragment's endpoint receives: its own cookies under the names
93
+ * its endpoint gave them, plus — only when `includeHost` (the deployment's `forwardCredentials`
94
+ * rule) — the host's unscoped cookies. Never another fragment's. `null` when nothing is left.
95
+ */
96
+ export function scopeRequestCookies(header, partition, includeHost) {
97
+ if (!header)
98
+ return null;
99
+ const own = cookieScopePrefix(partition);
100
+ const mine = [];
101
+ const mineNames = new Set();
102
+ const host = [];
103
+ for (const { name: rawName, cookie } of parseCookieHeader(header)) {
104
+ const [browserPrefix, name] = splitBrowserPrefix(rawName);
105
+ if (name.startsWith(own)) {
106
+ const ownName = `${browserPrefix}${name.slice(own.length)}`;
107
+ mine.push(`${ownName}${cookie.slice(rawName.length)}`);
108
+ mineNames.add(ownName);
109
+ }
110
+ else if (!name.startsWith(SCOPE_MARK) && includeHost) {
111
+ host.push({ name: rawName, cookie });
112
+ }
113
+ }
114
+ // the fragment's own cookie wins over a host cookie of the same name: parsers take the first
115
+ // occurrence, and the host's usually came first
116
+ const out = [...mine, ...host.filter(({ name }) => !mineNames.has(name)).map(({ cookie }) => cookie)];
117
+ return out.length > 0 ? out.join('; ') : null;
118
+ }
119
+ /**
120
+ * The caller's cookies for a *shared* fragment under `forwardCredentials`: everything but the
121
+ * partitioned fragments' scoped cookies, which belong to their own fragments alone.
122
+ */
123
+ export function withoutScopedCookies(header) {
124
+ if (!header)
125
+ return null;
126
+ const out = parseCookieHeader(header)
127
+ .filter(({ name }) => !isScopedCookieName(name))
128
+ .map(({ cookie }) => cookie);
129
+ return out.length > 0 ? out.join('; ') : null;
130
+ }
131
+ /**
132
+ * Rewrites every `Set-Cookie` on `headers` into the partition's scope, in place, and drops
133
+ * `Clear-Site-Data`, with which one fragment's logout would wipe every other fragment's cookies
134
+ * and storage along with its own.
135
+ */
136
+ export function scopeResponseCookies(headers, partition) {
137
+ headers.delete('clear-site-data');
138
+ const lines = headers.getSetCookie();
139
+ if (lines.length === 0)
140
+ return;
141
+ headers.delete('set-cookie');
142
+ for (const line of lines) {
143
+ const scoped = scopeSetCookie(line, partition);
144
+ if (scoped)
145
+ headers.append('set-cookie', scoped);
146
+ }
147
+ }
148
+ //# sourceMappingURL=cookie-scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie-scope.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/cookie-scope.ts"],"names":[],"mappings":"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"}
package/src/gateway.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Principal, RegistryRefresh, RegistrySource, FragmentManifest } from './registry.js';
2
2
  import { DiscoveryOptions } from './discovery.js';
3
3
  import { type BreakerOptions } from './breaker.js';
4
+ import { type BaggageOptions } from './baggage.js';
4
5
  import { type TelemetryOptions } from './telemetry.js';
5
6
  export interface GatewayOptions {
6
7
  /** The fragment registry: inline manifests, a JSON URL, or an async loader. */
@@ -65,6 +66,13 @@ export interface GatewayOptions {
65
66
  * throws forwards nothing. The same decision applies to websocket upgrades.
66
67
  */
67
68
  forwardCredentials?: boolean | ((fragment: FragmentManifest) => boolean);
69
+ /**
70
+ * W3C `baggage` for fragment requests. By default a fragment receives none: the browser's header
71
+ * is dropped rather than copied into every backend. Allowlist the inbound keys to keep, with an
72
+ * optional pattern for their values, and add members of the gateway's own. Only fragments that
73
+ * `forwardCredentials` allows receive any. See `baggage.ts`.
74
+ */
75
+ baggage?: BaggageOptions;
68
76
  /**
69
77
  * More request headers that carry the caller's identity, stripped alongside `cookie`,
70
78
  * `authorization` and `proxy-authorization` whenever {@link GatewayOptions.forwardCredentials}
@@ -121,6 +129,27 @@ export interface GatewayOptions {
121
129
  * `sec-fetch-dest` into the edge's cache key. See `docs/braid-cdn.md`.
122
130
  */
123
131
  pierceCacheControl?: 'private' | 'preserve';
132
+ /**
133
+ * Whether a fragment's `capabilities.network` is turned into a policy on its realm.
134
+ *
135
+ * By default the grant is advisory on the trusted tier: it documents intent and feeds the
136
+ * registry's analysis. With this set, the realm stub of every compat or custom-element fragment
137
+ * that declares one carries `connect-src 'self' <its origins>`:
138
+ *
139
+ * - `'report'` — as `Content-Security-Policy-Report-Only`. Nothing is blocked; the client logs
140
+ * each request that would have been, under the fragment's name. Start here.
141
+ * - `'enforce'` — as `Content-Security-Policy`. The browser blocks the realm's own `fetch`, XHR,
142
+ * WebSocket and EventSource requests to any other origin (and JSON module imports, which CSP
143
+ * counts as connections).
144
+ *
145
+ * It governs what the fragment's realm does, not what the host document does: markup the
146
+ * fragment puts in the page is under the host's policy, and so is anything the realm calls on the
147
+ * host's behalf — `navigator.sendBeacon` (a compat realm's `navigator` is the host's), and any
148
+ * `sharedGlobals` client. Code that deliberately reaches into the host realm gets around it too. It is a tripwire for accidents and naive supply-chain
149
+ * exfiltration, not a security boundary — the trusted tier has none. Fragments that declare no
150
+ * `network` are untouched.
151
+ */
152
+ realmPolicy?: 'report' | 'enforce';
124
153
  /**
125
154
  * Called for every document request that reaches the shell, with which fragments composed into
126
155
  * it. This is what makes traffic-informed impact analysis possible: "after this change, 43 of
@@ -261,6 +290,24 @@ export interface UpgradeTarget {
261
290
  credentialHeaders: readonly string[];
262
291
  /** The `x-braid-via` value for the upstream request, this gateway's hop included. */
263
292
  via: string;
293
+ /**
294
+ * When present, the `Cookie` header the upgrade carries *instead of* the caller's (after the
295
+ * credential strip), or `null` for none: a partitioned fragment's own scoped cookies, or the
296
+ * caller's minus every partitioned fragment's (see cookie-scope.ts). A binding must apply it, or
297
+ * it forwards cookies that belong to other fragments.
298
+ */
299
+ cookie?: string | null;
300
+ /**
301
+ * For a partitioned fragment, rewrites a `Set-Cookie` line from the upstream's handshake response
302
+ * into the fragment's scope; an empty result means drop it. A binding must apply it to every
303
+ * `Set-Cookie` it relays to the browser.
304
+ */
305
+ setCookie?: (line: string) => string;
306
+ /**
307
+ * The `baggage` header the upgrade carries instead of the caller's, or `null` for none (see
308
+ * `GatewayOptions.baggage`). A binding must apply it: the caller's own is never forwarded as is.
309
+ */
310
+ baggage?: string | null;
264
311
  }
265
312
  /** What the gateway tells a `documents` hook about the registry it is serving right now. */
266
313
  export interface DocumentsContext {
@@ -290,7 +337,9 @@ export interface BraidGateway {
290
337
  *
291
338
  * A binding other than `toNodeUpgradeHandler` **must** remove `credentialHeaders` from the
292
339
  * upstream request when `forwardCredentials` is false, and set `x-braid-via` to `via` — the
293
- * socket otherwise carries the user's identity past the boundary every fetch respects.
340
+ * socket otherwise carries the user's identity past the boundary every fetch respects. It must
341
+ * also replace `cookie` with `cookie` when the target carries one, and pass every `Set-Cookie`
342
+ * of the handshake response through `setCookie` when the target carries that.
294
343
  *
295
344
  * @returns the endpoint URL to dial, whether the caller's credentials may go with it (the same
296
345
  * {@link GatewayOptions.forwardCredentials} rule as a fetch) and which headers carry