@braidlabs/gateway 0.5.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 +6 -0
- package/package.json +1 -1
- package/src/baggage.d.ts +34 -0
- package/src/baggage.js +93 -0
- package/src/baggage.js.map +1 -0
- package/src/cookie-scope.d.ts +41 -0
- package/src/cookie-scope.js +148 -0
- package/src/cookie-scope.js.map +1 -0
- package/src/gateway.d.ts +50 -1
- package/src/gateway.js +178 -17
- package/src/gateway.js.map +1 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/node.js +17 -1
- package/src/node.js.map +1 -1
- package/src/registry.d.ts +33 -1
- package/src/registry.js +34 -0
- package/src/registry.js.map +1 -1
- package/src/rewriter/transforms.d.ts +19 -0
- package/src/rewriter/transforms.js +131 -13
- package/src/rewriter/transforms.js.map +1 -1
- package/src/single-flight.js +2 -0
- package/src/single-flight.js.map +1 -1
- package/src/telemetry.d.ts +39 -1
- package/src/telemetry.js.map +1 -1
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.
|
|
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",
|
package/src/baggage.d.ts
ADDED
|
@@ -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
|