@braidlabs/gateway 0.7.1 → 0.8.1

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
@@ -303,6 +303,34 @@ It only works end to end once the fragment can see the user: `forwardCredentials
303
303
  header via `additionalHeaders`. The gateway warns when a fragment opts in and neither is set — at startup for an inline registry, on the
304
304
  first redirect for a URL or loader registry.
305
305
 
306
+ ## Per-user preferences (opt-in)
307
+
308
+ `preferences` serves each signed-in user's own JSON entries at `/__braid/preferences/*`. It is what
309
+ `serverStore()` from `@braidlabs/console/workspace` talks to, so preferences, app settings and
310
+ saved layouts follow the user to any browser.
311
+
312
+ ```ts
313
+ import { filePreferenceBackend } from '@braidlabs/gateway/node';
314
+
315
+ createGateway({
316
+ registry,
317
+ principal: async (request) => {
318
+ const session = await sessions.read(request);
319
+ return session && { subject: session.userId, roles: session.roles };
320
+ },
321
+ preferences: { backend: filePreferenceBackend({ directory: '/var/lib/braid/preferences' }) },
322
+ });
323
+ ```
324
+
325
+ - Entries are keyed by the principal's `subject`, which must come from your session and be
326
+ globally unique (`${iss}|${sub}` across identity providers). No subject, 401.
327
+ - Same-origin only: a cross-site `Sec-Fetch-Site`, or a foreign `Origin` when that is absent, gets
328
+ 403, reads included.
329
+ - Bounded: 64 KiB a value, 1000 entries and 1 MiB a user, enforced by the backend in the same
330
+ write (413 past any of them). Every answer is `private, no-store`.
331
+ - Backends: `memoryPreferenceBackend()` for tests, `filePreferenceBackend()` for one Node process,
332
+ or your own `PreferenceBackend` over a database behind several instances. Full details: [gateway docs](../../docs/packages/gateway.md#per-user-preferences-opt-in).
333
+
306
334
  ## Discovery endpoint (optional)
307
335
 
308
336
  For shells that build their UI from the registry rather than hard-coding slot names — a launcher,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braidlabs/gateway",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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",
@@ -34,9 +34,12 @@ export function storagePartitionOf(fragment) {
34
34
  if (grant === 'partitioned')
35
35
  return fragment.id;
36
36
  if (grant && typeof grant === 'object') {
37
- const partition = grant.partition;
37
+ const { partition, databases } = grant;
38
38
  if (typeof partition === 'string' && partition !== '')
39
39
  return partition;
40
+ // as on the client: databases of its own imply a partition named for the fragment
41
+ if (databases === true && partition === undefined)
42
+ return fragment.id;
40
43
  }
41
44
  return null;
42
45
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cookie-scope.js","sourceRoot":"","sources":["../../../../libs/braid-gateway/src/cookie-scope.ts"],"names":[],"mappings":"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"}
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,5 +1,6 @@
1
1
  import { Principal, RegistryRefresh, RegistrySource, FragmentManifest } from './registry.js';
2
2
  import { DiscoveryOptions } from './discovery.js';
3
+ import { type PreferencesOptions } from './preferences.js';
3
4
  import { type BreakerOptions } from './breaker.js';
4
5
  import { type BaggageOptions } from './baggage.js';
5
6
  import { type TelemetryOptions } from './telemetry.js';
@@ -29,7 +30,13 @@ export interface GatewayOptions {
29
30
  * for document requests (GET, `Sec-Fetch-Dest: document` or an HTML `Accept`).
30
31
  */
31
32
  documents?: (request: Request, context: DocumentsContext) => Response | null | Promise<Response | null>;
32
- /** 'development' enables verbose error bodies; defaults to 'development'. */
33
+ /**
34
+ * Defaults to 'production'.
35
+ *
36
+ * 'development' is for a gateway on your own machine: it **skips every `access` rule**, lists
37
+ * every fragment with its endpoint in discovery, sends verbose error bodies, and rewrites
38
+ * `Origin` to the endpoint's own. Never set it on a gateway other people can reach.
39
+ */
33
40
  mode?: 'production' | 'development';
34
41
  /**
35
42
  * Additional headers set on every request forwarded to a fragment endpoint.
@@ -92,6 +99,13 @@ export interface GatewayOptions {
92
99
  * See {@link DiscoveryOptions}.
93
100
  */
94
101
  discovery?: DiscoveryOptions;
102
+ /**
103
+ * Serves each signed-in user's preferences at `/__braid/preferences/*`, for the workspace's
104
+ * `serverStore` (`@braidlabs/console/workspace`): preferences, app settings and saved layouts. Entries are keyed by
105
+ * the `subject` your `principal` resolver returns; a request without one is refused with 401.
106
+ * Off unless configured. See {@link PreferencesOptions}.
107
+ */
108
+ preferences?: PreferencesOptions;
95
109
  /**
96
110
  * Resolves who is asking, for manifests that declare `access` rules.
97
111
  *
@@ -227,6 +241,17 @@ export interface GatewayOptions {
227
241
  * on an origin it does not own.
228
242
  */
229
243
  serviceWorker?: boolean | ServiceWorkerOptions;
244
+ /**
245
+ * Tell the browser which links it may fetch or render ahead of a click (Speculation Rules,
246
+ * Chromium), from the fragments whose manifest sets `speculate`.
247
+ *
248
+ * Every HTML document the gateway serves or passes through then carries
249
+ * `Speculation-Rules: "/__braid/speculation-rules.json"`. The header is the same for everyone,
250
+ * so a cached page stays shareable. The rules it points to are built per request from the
251
+ * registry: a fragment the caller may not fetch is left out, and when any fragment has access
252
+ * rules the response is private to the caller. Off by default.
253
+ */
254
+ speculationRules?: boolean;
230
255
  /**
231
256
  * The path this gateway is mounted under, for a host that shares its domain and does not own the
232
257
  * root (`/manage`). The reserved namespaces move under it — `/manage/__braid/frag/…` — and the
@@ -328,6 +353,13 @@ export interface BraidGateway {
328
353
  * document requests are passed through and fragments boot client-side instead.
329
354
  */
330
355
  handle(request: Request, next?: () => Promise<Response>): Promise<Response | null>;
356
+ /**
357
+ * Headers a binding adds to a page it passes to the app when `handle` returned null — the
358
+ * pointer to the speculation rules, when `speculationRules` is on — or null for none. Set on the
359
+ * way out rather than by reading the app's response, so a passed-through page streams exactly as
360
+ * it would without the gateway. Browsers ignore them on anything but HTML.
361
+ */
362
+ pageHeaders(request: Request): Record<string, string> | null;
331
363
  /**
332
364
  * Resolves a websocket upgrade addressed to a fragment.
333
365
  *
@@ -393,5 +425,8 @@ export declare function toFetchHandler(gateway: BraidGateway, appHandler: (reque
393
425
  * ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
394
426
  * such a request out of the fragment namespace entirely before it ever reaches here. The check
395
427
  * costs nothing and does not assume every runtime normalizes identically.
428
+ *
429
+ * What URL parsing does not normalize is a separator or dot segment hidden inside a segment;
430
+ * see {@link assertForwardablePath}, which runs first.
396
431
  */
397
432
  export declare function resolveEndpointUrl(endpoint: string, strippedUrl: URL, fragmentId: string): URL;
package/src/gateway.js CHANGED
@@ -1,6 +1,8 @@
1
- import { BRAID_ADAPTER_META, BRAID_ADAPTER_OPTIONS_META, BRAID_FRAGMENT_ID_HEADER, BRAID_MAX_HOPS, BRAID_PROTOCOL_META, BRAID_PROTOCOL_VERSION, BRAID_REDIRECT_LOCATION_HEADER, BRAID_RETURN_URL_HEADER, braidFragmentUrl, parseBraidPathname, BRAID_SERVICE_WORKER_PATH, BRAID_VITALS_BEACON_PATH, BRAID_VITALS_SCRIPT_PATH, BRAID_VIA_HEADER, } from './protocol.js';
1
+ import { BRAID_ADAPTER_META, BRAID_ADAPTER_OPTIONS_META, BRAID_FRAGMENT_ID_HEADER, BRAID_MAX_HOPS, BRAID_PROTOCOL_META, BRAID_PROTOCOL_VERSION, BRAID_REDIRECT_LOCATION_HEADER, BRAID_RETURN_URL_HEADER, braidFragmentUrl, parseBraidPathname, BRAID_SERVICE_WORKER_PATH, BRAID_SPECULATION_RULES_PATH, BRAID_VITALS_BEACON_PATH, BRAID_VITALS_SCRIPT_PATH, BRAID_VIA_HEADER, } from './protocol.js';
2
2
  import { canFetch, canList, hasAccessRules, isOriginSource, Registry, } from './registry.js';
3
3
  import { createDiscoveryHandler } from './discovery.js';
4
+ import { createPreferencesHandler } from './preferences.js';
5
+ import { speculationRulesFor } from './speculation.js';
4
6
  import { createBreaker } from './breaker.js';
5
7
  import { createSingleFlight, singleFlightKey } from './single-flight.js';
6
8
  import { scopeRequestCookies, scopeResponseCookies, scopeSetCookie, storagePartitionOf, withoutScopedCookies } from './cookie-scope.js';
@@ -33,7 +35,9 @@ export function createGateway(options) {
33
35
  throw new Error(`braid-gateway: registryRefreshMs must be a positive number of milliseconds, got ${options.registryRefreshMs}`);
34
36
  }
35
37
  }
36
- const mode = options.mode ?? 'development';
38
+ // Production unless asked: development skips access rules, so it must never be what a
39
+ // deployment gets for forgetting an option.
40
+ const mode = options.mode ?? 'production';
37
41
  const pierceCacheControl = options.pierceCacheControl ?? 'private';
38
42
  const realmPolicy = options.realmPolicy;
39
43
  if (realmPolicy !== undefined && realmPolicy !== 'report' && realmPolicy !== 'enforce') {
@@ -147,7 +151,9 @@ export function createGateway(options) {
147
151
  const unsupported = [
148
152
  serviceWorker && 'serviceWorker',
149
153
  options.discovery && 'discovery',
154
+ options.preferences && 'preferences',
150
155
  vitalsEnabled && 'telemetry.webVitals',
156
+ options.speculationRules && 'speculationRules',
151
157
  ].filter(Boolean);
152
158
  if (unsupported.length) {
153
159
  throw new Error(`braid-gateway: basePath "${basePath}" is not supported together with ${unsupported.join(', ')} yet — ` +
@@ -259,8 +265,10 @@ export function createGateway(options) {
259
265
  */
260
266
  const resolvePrincipal = async (request) => options.principal ? ((await options.principal(request)) ?? undefined) : undefined;
261
267
  const discovery = createDiscoveryHandler(registry, options.discovery, mode, resolvePrincipal);
268
+ const preferences = createPreferencesHandler(options.preferences, resolvePrincipal);
262
269
  return {
263
270
  refreshRegistry,
271
+ pageHeaders,
264
272
  close() {
265
273
  closed = true;
266
274
  clearTimeout(refreshTimer);
@@ -275,6 +283,9 @@ export function createGateway(options) {
275
283
  if (discovery?.owns(requestUrl.pathname)) {
276
284
  return discovery.handle(request, requestUrl);
277
285
  }
286
+ if (preferences?.owns(requestUrl.pathname)) {
287
+ return preferences.handle(request, requestUrl);
288
+ }
278
289
  if (serviceWorker && requestUrl.pathname === BRAID_SERVICE_WORKER_PATH) {
279
290
  return serviceWorkerResponse(serviceWorker);
280
291
  }
@@ -291,36 +302,14 @@ export function createGateway(options) {
291
302
  if (vitalsEnabled && requestUrl.pathname === BRAID_VITALS_BEACON_PATH) {
292
303
  return handleVitalsBeacon(request);
293
304
  }
305
+ if (options.speculationRules && requestUrl.pathname === BRAID_SPECULATION_RULES_PATH) {
306
+ return speculationRulesResponse(request);
307
+ }
294
308
  const route = braidRoute(requestUrl.pathname);
295
309
  if (!route) {
296
- // HEAD gets the same answer without the body, so a monitor or link checker in front of a
297
- // gateway with no app behind it sees the page a browser would.
298
- if (options.documents && request.method === 'HEAD') {
299
- const asGet = new Request(request.url, { method: 'GET', headers: request.headers });
300
- const document = await options.documents(asGet, { manifests: await registry.current() });
301
- if (document) {
302
- const headers = new Headers(document.headers);
303
- headers.append('vary', 'sec-fetch-dest');
304
- applyPierceCacheControl(headers);
305
- await document.body?.cancel();
306
- return new Response(null, { status: document.status, headers });
307
- }
308
- }
309
- const document = options.documents && isDocumentRequest(request)
310
- ? await options.documents(request, { manifests: await registry.current() })
311
- : null;
312
- if (document) {
313
- // The defined document is the shell: pierced like an app's response when fragments match,
314
- // and served as it is when none do.
315
- const composed = await handleShellRequest(request, requestUrl, async () => document);
316
- if (composed)
317
- return composed;
318
- const served = new Response(document.body, document);
319
- served.headers.append('vary', 'sec-fetch-dest');
320
- applyPierceCacheControl(served.headers);
321
- return served;
322
- }
323
- return next ? handleShellRequest(request, requestUrl, next) : null;
310
+ // A page the gateway leaves to the app comes back null, untouched: the binding adds the
311
+ // page headers on its way through, without the gateway reading the app's response.
312
+ return withPageHeaders(request, await pageResponse(request, requestUrl, next));
324
313
  }
325
314
  /**
326
315
  * Braid requests are routed to the addressed fragment exactly: an unknown fragment id is a
@@ -480,7 +469,7 @@ export function createGateway(options) {
480
469
  if (!result.ok && result.outOfScope) {
481
470
  console.warn(String(result.error));
482
471
  return htmlResponse(mode === 'development'
483
- ? `<p>braid-gateway: that path is outside the endpoint declared by fragment "${escapeHtml(fragment.id)}".</p>`
472
+ ? `<p>braid-gateway: that path is outside the endpoint declared by fragment "${escapeHtml(fragment.id)}", or hides a path separator or dot segment.</p>`
484
473
  : '<p>Not found</p>', 404, { [BRAID_FRAGMENT_ID_HEADER]: fragment.id });
485
474
  }
486
475
  if (!result.ok) {
@@ -631,7 +620,19 @@ export function createGateway(options) {
631
620
  // The caller composes the whole path, query included. Whether the page's query belongs on this
632
621
  // request is a question only the caller can answer — a namespace request forwards it, a bound
633
622
  // fragment renders it, and to a widget the host's `?tab=` means nothing at all.
634
- const strippedUrl = new URL(path, requestUrl);
623
+ // Assigned, never parsed against the request: as a string, a path starting `//` names another
624
+ // host, and a function endpoint would be handed a request for it.
625
+ const queryAt = path.indexOf('?');
626
+ const strippedUrl = new URL(requestUrl.origin);
627
+ strippedUrl.pathname = queryAt === -1 ? path : path.slice(0, queryAt);
628
+ strippedUrl.search = queryAt === -1 ? '' : path.slice(queryAt);
629
+ // every endpoint, a function too: it has no path to stay inside, and may still proxy or serve files
630
+ try {
631
+ assertForwardablePath(strippedUrl.pathname, fragment.id);
632
+ }
633
+ catch (error) {
634
+ return { ok: false, error, timedOut: false, outOfScope: error instanceof EndpointScopeError };
635
+ }
635
636
  let fragmentRequestUrl;
636
637
  let fragmentFetch;
637
638
  if (typeof endpoint === 'function') {
@@ -698,6 +699,9 @@ export function createGateway(options) {
698
699
  // browser's `zstd` would come back decoded by some runtimes and not others (see `forwardable`)
699
700
  if (fragmentFetch === globalThis.fetch)
700
701
  fragmentRequest.headers.set('accept-encoding', [...FETCH_DECODED_CODINGS].join(', '));
702
+ // A browser (the gateway in a service worker) forbids the header and drops it without a word:
703
+ // the browser then picks the codings itself and decodes every one it picked.
704
+ const codingsPinned = fragmentRequest.headers.has('accept-encoding');
701
705
  fragmentRequest.headers.set('x-braid-fragment-mode', 'embedded');
702
706
  /**
703
707
  * In development, present the request as coming from the endpoint's own origin.
@@ -769,7 +773,7 @@ export function createGateway(options) {
769
773
  };
770
774
  const sentResponse = key ? await singleFlight.run(key, send) : await send();
771
775
  // a function endpoint hands over its own bytes; only the platform fetch decodes them
772
- const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch);
776
+ const response = forwardable(sentResponse, fragmentFetch === globalThis.fetch, codingsPinned);
773
777
  // A 5xx is a reachable endpoint, but it is not a working one — a fragment returning 500 on
774
778
  // every request would otherwise hold the circuit closed forever and keep paying for it.
775
779
  if (response.status >= 500)
@@ -805,6 +809,83 @@ export function createGateway(options) {
805
809
  return { ok: false, error, timedOut: timeoutSignal.aborted };
806
810
  }
807
811
  }
812
+ /** A request outside the Braid namespace: a page, composed or passed to the shell. */
813
+ async function pageResponse(request, requestUrl, next) {
814
+ // HEAD gets the same answer without the body, so a monitor or link checker in front of a
815
+ // gateway with no app behind it sees the page a browser would.
816
+ if (options.documents && request.method === 'HEAD') {
817
+ const asGet = new Request(request.url, { method: 'GET', headers: request.headers });
818
+ const document = await options.documents(asGet, { manifests: await registry.current() });
819
+ if (document) {
820
+ const headers = new Headers(document.headers);
821
+ headers.append('vary', 'sec-fetch-dest');
822
+ applyPierceCacheControl(headers);
823
+ await document.body?.cancel();
824
+ return new Response(null, { status: document.status, headers });
825
+ }
826
+ }
827
+ const document = options.documents && isDocumentRequest(request)
828
+ ? await options.documents(request, { manifests: await registry.current() })
829
+ : null;
830
+ if (document) {
831
+ // The defined document is the shell: pierced like an app's response when fragments match,
832
+ // and served as it is when none do.
833
+ const composed = await handleShellRequest(request, requestUrl, async () => document);
834
+ if (composed)
835
+ return composed;
836
+ const served = new Response(document.body, document);
837
+ served.headers.append('vary', 'sec-fetch-dest');
838
+ applyPierceCacheControl(served.headers);
839
+ return served;
840
+ }
841
+ return next ? handleShellRequest(request, requestUrl, next) : null;
842
+ }
843
+ /**
844
+ * The headers a page carries because of this gateway, for a browser navigation: today, the
845
+ * pointer to the speculation rules.
846
+ *
847
+ * Every page, not only composed ones: the page that *links* to a fragment's route is the one the
848
+ * browser speculates from, and it is usually not that route. The value is the same for every
849
+ * caller, so it never makes a page private, and a browser ignores it on anything but HTML.
850
+ */
851
+ function pageHeaders(request) {
852
+ if (!options.speculationRules || !isNavigation(request))
853
+ return null;
854
+ return { 'speculation-rules': `"${BRAID_SPECULATION_RULES_PATH}"` };
855
+ }
856
+ /** A page the gateway produced, with {@link pageHeaders} on it. */
857
+ function withPageHeaders(request, page) {
858
+ const headers = page && !isOpaque(page) ? pageHeaders(request) : null;
859
+ return headers ? withHeaders(page, headers) : page;
860
+ }
861
+ /**
862
+ * The rules themselves: the pierce patterns of every fragment that asks for speculation, less
863
+ * those the caller may not list or fetch — as discovery and the namespace keep them out of sight.
864
+ *
865
+ * Private whenever the gateway can tell callers apart (a `principal` resolver) or any fragment
866
+ * has access rules: decided from configuration, so a shared cache never holds a list that a
867
+ * registry refresh has since made personal. Never stored in development, where nothing is filtered.
868
+ */
869
+ async function speculationRulesResponse(request) {
870
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
871
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
872
+ }
873
+ const manifests = await registry.listFragments();
874
+ const personal = !isDevelopment && (options.principal !== undefined || manifests.some(hasAccessRules));
875
+ const principal = personal ? await resolvePrincipal(request) : undefined;
876
+ const visible = manifests.filter((manifest) => isDevelopment || !hasAccessRules(manifest) || (canList(manifest, principal) && canFetch(manifest, principal)));
877
+ return new Response(request.method === 'HEAD' ? null : JSON.stringify(speculationRulesFor(visible)), {
878
+ headers: {
879
+ 'content-type': 'application/speculationrules+json',
880
+ ...(isDevelopment
881
+ ? { 'cache-control': 'no-store' }
882
+ : personal
883
+ ? { 'cache-control': 'private, no-store', vary: 'cookie, authorization' }
884
+ : // short: the registry it comes from refreshes on its own schedule
885
+ { 'cache-control': 'public, max-age=60' }),
886
+ },
887
+ });
888
+ }
808
889
  /**
809
890
  * Handles a request bound for the shell application.
810
891
  *
@@ -848,6 +929,8 @@ export function createGateway(options) {
848
929
  }
849
930
  }
850
931
  const shell = await next();
932
+ if (isOpaque(shell))
933
+ return shell;
851
934
  const isNullBody = shell.status === 204 ||
852
935
  shell.status === 205 ||
853
936
  shell.status === 304 ||
@@ -893,6 +976,10 @@ export function createGateway(options) {
893
976
  cancelFragmentBodies();
894
977
  throw error;
895
978
  }
979
+ if (isOpaque(shellResponse)) {
980
+ cancelFragmentBodies();
981
+ return shellResponse;
982
+ }
896
983
  const isShellNullBody = shellResponse.status === 204 ||
897
984
  shellResponse.status === 205 ||
898
985
  shellResponse.status === 304 ||
@@ -1093,7 +1180,7 @@ export function createGateway(options) {
1093
1180
  export function toWebMiddleware(gateway) {
1094
1181
  return async (request, next) => {
1095
1182
  const shellOnce = once(next);
1096
- return (await gateway.handle(request, shellOnce)) ?? shellOnce();
1183
+ return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
1097
1184
  };
1098
1185
  }
1099
1186
  /**
@@ -1113,9 +1200,14 @@ export function toWebMiddleware(gateway) {
1113
1200
  export function toFetchHandler(gateway, appHandler) {
1114
1201
  return async (request) => {
1115
1202
  const shellOnce = once(() => Promise.resolve(appHandler(request)));
1116
- return (await gateway.handle(request, shellOnce)) ?? shellOnce();
1203
+ return (await gateway.handle(request, shellOnce)) ?? passThrough(gateway, request, await shellOnce());
1117
1204
  };
1118
1205
  }
1206
+ /** The app's own response to a request the gateway passed to it, with the gateway's page headers. */
1207
+ function passThrough(gateway, request, response) {
1208
+ const headers = isOpaque(response) ? null : gateway.pageHeaders(request);
1209
+ return headers ? withHeaders(response, headers) : response;
1210
+ }
1119
1211
  /** Memoizes an async thunk so the wrapped work happens at most once. */
1120
1212
  function once(fn) {
1121
1213
  let pending;
@@ -1163,8 +1255,12 @@ function normalizeBasePath(basePath) {
1163
1255
  * ones (`%2e%2e`), are already normalized when the incoming request URL is parsed, which takes
1164
1256
  * such a request out of the fragment namespace entirely before it ever reaches here. The check
1165
1257
  * costs nothing and does not assume every runtime normalizes identically.
1258
+ *
1259
+ * What URL parsing does not normalize is a separator or dot segment hidden inside a segment;
1260
+ * see {@link assertForwardablePath}, which runs first.
1166
1261
  */
1167
1262
  export function resolveEndpointUrl(endpoint, strippedUrl, fragmentId) {
1263
+ assertForwardablePath(strippedUrl.pathname, fragmentId);
1168
1264
  const endpointUrl = new URL(endpoint);
1169
1265
  const basePath = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
1170
1266
  // Assigned, never parsed: as a string, a path starting `//` is a protocol-relative URL naming
@@ -1181,6 +1277,52 @@ export function resolveEndpointUrl(endpoint, strippedUrl, fragmentId) {
1181
1277
  /** Thrown when a namespace request would reach outside its fragment endpoint's declared path. */
1182
1278
  class EndpointScopeError extends Error {
1183
1279
  }
1280
+ /** How many layers of percent-encoding are looked through. An upstream chain rarely decodes more than twice. */
1281
+ const MAX_DECODE_PASSES = 3;
1282
+ /**
1283
+ * Refuses a path that could mean something else to an upstream than it does as a string: one with
1284
+ * a segment that — once decoded, as far as it decodes — is a dot segment, or holds a path
1285
+ * separator or a NUL. Applied to every fragment request, whatever kind of endpoint serves it.
1286
+ *
1287
+ * - `..%2f..%2fadmin` is one segment to a URL parser, and `../../admin` to an upstream that
1288
+ * decodes `%2F` before resolving.
1289
+ * - `..;/` is not a dot segment to a URL parser, and is one to a servlet container, which drops
1290
+ * `;parameters` before resolving. So the part before the first `;` is what is tested.
1291
+ * - Compatibility forms are folded (NFKC) before testing: `..` and `/` are `..` and `/` to a
1292
+ * file system that best-fits them.
1293
+ *
1294
+ * A segment that is not valid percent-encoding (a bare `%`) is refused too, rather than guessed at.
1295
+ */
1296
+ function assertForwardablePath(pathname, fragmentId) {
1297
+ const refuse = (why) => {
1298
+ throw new EndpointScopeError(`braid-gateway: a request for fragment "${fragmentId}" has ${why}, which could resolve outside the fragment's ` +
1299
+ `endpoint upstream — refusing to forward it`);
1300
+ };
1301
+ for (const segment of pathname.split('/')) {
1302
+ let decoded = segment;
1303
+ for (let pass = 0; pass < MAX_DECODE_PASSES && decoded.includes('%'); pass++) {
1304
+ let next;
1305
+ try {
1306
+ next = decodeURIComponent(decoded);
1307
+ }
1308
+ catch {
1309
+ // the segment as sent must decode; a later layer that does not is just text
1310
+ if (pass === 0)
1311
+ refuse('a path segment that is not valid percent-encoding');
1312
+ break;
1313
+ }
1314
+ if (next === decoded)
1315
+ break;
1316
+ decoded = next;
1317
+ }
1318
+ const folded = decoded.normalize('NFKC');
1319
+ const bare = folded.split(';', 1)[0].replace(/[ \t]+$/, '');
1320
+ if (bare === '.' || bare === '..')
1321
+ refuse('a dot segment hidden by encoding or path parameters');
1322
+ if (/[/\\\0]/.test(folded))
1323
+ refuse('an encoded path separator');
1324
+ }
1325
+ }
1184
1326
  /**
1185
1327
  * The breaker refused a request without making it.
1186
1328
  *
@@ -1244,6 +1386,35 @@ function escapeHtml(value) {
1244
1386
  function appendHop(request) {
1245
1387
  return [request.headers.get(BRAID_VIA_HEADER), 'braid'].filter(Boolean).join(', ');
1246
1388
  }
1389
+ /**
1390
+ * A response with nothing to compose or annotate: a browser's opaque redirect (status 0), which is
1391
+ * what a navigation's own `fetch` returns when the shell redirects, or a network error. Neither can
1392
+ * be copied into a new `Response`, and both are for the browser to act on as they are.
1393
+ */
1394
+ function isOpaque(response) {
1395
+ return response.status === 0;
1396
+ }
1397
+ /** A top-level browser navigation: the request a page's own headers are for. */
1398
+ function isNavigation(request) {
1399
+ return request.method === 'GET' && request.headers.get('sec-fetch-dest') === 'document';
1400
+ }
1401
+ /**
1402
+ * The response with these headers set. A response's headers are often immutable (one `fetch`
1403
+ * returned), so it is copied then — the body streams through the copy untouched.
1404
+ */
1405
+ function withHeaders(response, headers) {
1406
+ try {
1407
+ for (const [name, value] of Object.entries(headers))
1408
+ response.headers.set(name, value);
1409
+ return response;
1410
+ }
1411
+ catch {
1412
+ const copy = new Response(response.body, response);
1413
+ for (const [name, value] of Object.entries(headers))
1414
+ copy.headers.set(name, value);
1415
+ return copy;
1416
+ }
1417
+ }
1247
1418
  /** How many Braid gateways a request has already crossed, from its `x-braid-via` entries. */
1248
1419
  function hopCount(request) {
1249
1420
  const via = request.headers.get(BRAID_VIA_HEADER);
@@ -1274,8 +1445,11 @@ const HOP_BY_HOP_HEADERS = ['connection', 'keep-alive', 'proxy-connection', 'tra
1274
1445
  * Only what the platform `fetch` returned: a function endpoint hands over its own bytes and is
1275
1446
  * trusted to describe them — one that wraps `fetch` must drop these headers itself. On Workers,
1276
1447
  * which would re-encode a passed-through body, this costs the compression of that one hop.
1448
+ *
1449
+ * Where the codings could not be pinned (`codingsPinned` false: a browser, which forbids setting
1450
+ * `accept-encoding`), the browser asked for them and decodes all it asked for, so none is refused.
1277
1451
  */
1278
- function forwardable(response, decodedByFetch) {
1452
+ function forwardable(response, decodedByFetch, codingsPinned = true) {
1279
1453
  const encoding = response.headers.get('content-encoding');
1280
1454
  const connection = response.headers.get('connection');
1281
1455
  const reframe = decodedByFetch && encoding !== null;
@@ -1283,7 +1457,7 @@ function forwardable(response, decodedByFetch) {
1283
1457
  // runtime's fetch decoded it varies — Node's decodes zstd — so neither the length nor the coding
1284
1458
  // can be said to describe the body. Refused, the same on every runtime, rather than guessed at.
1285
1459
  const codings = encoding?.split(',').map((coding) => coding.trim().toLowerCase()).filter(Boolean) ?? [];
1286
- if (reframe && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
1460
+ if (reframe && codingsPinned && codings.some((coding) => coding !== 'identity' && !FETCH_DECODED_CODINGS.has(coding))) {
1287
1461
  void response.body?.cancel();
1288
1462
  return new Response(`braid-gateway: the endpoint answered in content-encoding "${encoding}", which was not requested`, {
1289
1463
  status: 502,