@mirrorstack-ai/app-module-client 0.5.1 → 0.6.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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ All notable changes to this package are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## 0.6.0
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `createModuleProxyRoutes` no longer replays the CDN edge's own headers
|
|
13
|
+
upstream. On a hosted tenant the request reaches the app carrying
|
|
14
|
+
Cloudflare's `cf-connecting-ip`; forwarding it into the platform's
|
|
15
|
+
Cloudflare zone made Cloudflare answer the call itself — `403 text/html`,
|
|
16
|
+
"Error reference number: 1000" — so **every** browser-side module call on a
|
|
17
|
+
hosted tenant failed, with a page no module client could parse.
|
|
18
|
+
|
|
19
|
+
Measured against the live API, 12 trials per condition: a clean request
|
|
20
|
+
answered `401` JSON 12/12; adding `cf-connecting-ip` produced the 1000 page
|
|
21
|
+
12/12. Two findings worth keeping: `cf-connecting-ip` is the trigger
|
|
22
|
+
(`x-forwarded-for`, `true-client-ip`, `cf-ray` and `cf-visitor` each changed
|
|
23
|
+
nothing), and `cdn-loop: cloudflare` SUPPRESSES it — so stripping `cdn-loop`
|
|
24
|
+
while still forwarding `cf-connecting-ip` would have made the failure
|
|
25
|
+
certain rather than fixing it.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Breaking for anyone relying on header pass-through.** Request headers are
|
|
30
|
+
now an ALLOWLIST — `accept`, `accept-language`, `content-type`, the
|
|
31
|
+
conditional-request headers, `range` and `user-agent` — rather than
|
|
32
|
+
"everything except a named few". A denylist can only exclude headers someone
|
|
33
|
+
thought of, which is exactly how this bug happened; the next CDN in front of
|
|
34
|
+
a tenant (`fastly-client-ip`, `akamai-*`, `x-amzn-*`) would have reproduced
|
|
35
|
+
it. Unknown no longer travels.
|
|
36
|
+
|
|
37
|
+
- New `extraRequestHeaders` option on `createModuleProxyRoutes` for an app or
|
|
38
|
+
module that genuinely needs a custom header from the browser, so the
|
|
39
|
+
allowlist does not silently drop a feature. A name that describes the network
|
|
40
|
+
path (`cf-*`, `x-forwarded-*`, `cdn-loop`, `true-client-ip`, `forwarded`,
|
|
41
|
+
`via`, and other CDN families) is refused at mount rather than in production.
|
|
42
|
+
|
|
8
43
|
## 0.5.1
|
|
9
44
|
|
|
10
45
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# @mirrorstack-ai/app-module-client
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@mirrorstack-ai/app-module-client)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
Framework-neutral, typed composition for calling MirrorStack application
|
|
4
7
|
modules. The package builds dispatch URLs, applies injected transport policy,
|
|
5
8
|
and composes explicitly registered module plugins into one app client. It is
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* 🔴 WHY THIS EXISTS AT ALL. A custom app keeps its member credential in an
|
|
5
5
|
* HttpOnly cookie, which is the point of an HttpOnly cookie — script cannot
|
|
6
6
|
* read it, so the browser cannot call the platform itself. That is the ONLY
|
|
7
|
-
* thing the browser is missing. This supplies exactly that and forwards
|
|
8
|
-
*
|
|
7
|
+
* thing the browser is missing. This supplies exactly that and forwards the
|
|
8
|
+
* request otherwise unchanged — through a header ALLOWLIST, not untouched:
|
|
9
|
+
* a hosted tenant's request arrives carrying its CDN edge's own headers, and
|
|
10
|
+
* replaying those into the platform's CDN zone is what made every browser-side
|
|
11
|
+
* module call on a hosted tenant fail. See {@link FORWARDED_REQUEST_HEADERS}.
|
|
9
12
|
*
|
|
10
13
|
* 🔴 WHY IT IS HERE RATHER THAN IN EACH APP. Every app that keeps its
|
|
11
14
|
* credential in a cookie needs byte-identical code, and two lines of it are
|
|
@@ -45,6 +48,19 @@ export interface ModuleProxyRoutesOptions {
|
|
|
45
48
|
* sign-in cannot disagree about where the session lives.
|
|
46
49
|
*/
|
|
47
50
|
readonly readMemberCredential: () => Promise<string | null>;
|
|
51
|
+
/**
|
|
52
|
+
* Extra request headers to replay upstream, beyond the built-in allowlist.
|
|
53
|
+
*
|
|
54
|
+
* The allowlist is deliberately small, so an app or module that genuinely
|
|
55
|
+
* needs a custom header from the browser names it here rather than having it
|
|
56
|
+
* dropped silently. Matched case-insensitively.
|
|
57
|
+
*
|
|
58
|
+
* A name that describes the NETWORK PATH rather than the request is refused
|
|
59
|
+
* at construction — `cf-*`, `x-forwarded-*`, `true-client-ip`, `forwarded`,
|
|
60
|
+
* `cdn-loop` and friends are exactly what broke hosted tenants, and an
|
|
61
|
+
* escape hatch that let one back in would reopen the hole it exists beside.
|
|
62
|
+
*/
|
|
63
|
+
readonly extraRequestHeaders?: readonly string[];
|
|
48
64
|
/** Fetch implementation for the upstream call. Defaults to `globalThis.fetch`. */
|
|
49
65
|
readonly fetch?: typeof globalThis.fetch;
|
|
50
66
|
}
|
|
@@ -1,22 +1,97 @@
|
|
|
1
1
|
import { platformBaseUrl } from "../base-url.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Request headers this hop replays upstream. Everything else is dropped.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* 🔴 THIS IS AN ALLOWLIST, AND IT USED TO BE A DENYLIST. A denylist forwarded
|
|
6
|
+
* every header the request arrived with, which on a hosted tenant means every
|
|
7
|
+
* header the CDN edge added on the way in. Replaying Cloudflare's
|
|
8
|
+
* `cf-connecting-ip` into the api.mirrorstack.ai zone made Cloudflare answer
|
|
9
|
+
* the request itself — `403 text/html`, "Error reference number: 1000" — so
|
|
10
|
+
* EVERY browser-side module call on a hosted tenant failed, and failed with a
|
|
11
|
+
* page no module could interpret.
|
|
12
|
+
*
|
|
13
|
+
* Measured against the live API on 2026-09-12, 12 trials per condition:
|
|
14
|
+
*
|
|
15
|
+
* no edge headers → 401 JSON (0/12 gave 1000)
|
|
16
|
+
* + cf-connecting-ip → 1000 (12/12)
|
|
17
|
+
* + cf-connecting-ip and cdn-loop → 401 JSON (0/12)
|
|
18
|
+
*
|
|
19
|
+
* Two things follow, and the second is a trap. `cf-connecting-ip` is the
|
|
20
|
+
* trigger — `x-forwarded-for`, `true-client-ip`, `cf-ray` and `cf-visitor`
|
|
21
|
+
* each changed nothing. And `cdn-loop: cloudflare` SUPPRESSES it: Cloudflare
|
|
22
|
+
* reads the pair as a legitimate CDN-to-CDN hop. So stripping `cdn-loop` while
|
|
23
|
+
* still forwarding `cf-connecting-ip` would turn an intermittent failure into
|
|
24
|
+
* a certain one. An allowlist removes both together and cannot get that wrong.
|
|
25
|
+
*
|
|
26
|
+
* The deeper reason for the inversion: this bug was an intermediary adding a
|
|
27
|
+
* header nobody had thought of. A denylist can only ever exclude headers
|
|
28
|
+
* someone thought of, so the next CDN in front of a tenant — `fastly-client-ip`,
|
|
29
|
+
* `akamai-*`, `x-amzn-*` — would reproduce it exactly. Unknown does not travel.
|
|
30
|
+
*
|
|
31
|
+
* `cookie` and `authorization` are absent for their own reasons and would be
|
|
32
|
+
* even if no CDN existed: the app's session cookie is its own, and forwarding
|
|
33
|
+
* it hands a second credential to a service that never asked for one; this hop
|
|
34
|
+
* sets `authorization` itself, so a caller-supplied one must never survive.
|
|
35
|
+
* `content-length` and the hop-by-hop headers are re-derived by fetch.
|
|
10
36
|
*/
|
|
11
|
-
const
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
37
|
+
const FORWARDED_REQUEST_HEADERS = new Set([
|
|
38
|
+
"accept",
|
|
39
|
+
"accept-language",
|
|
40
|
+
"content-type",
|
|
41
|
+
// Conditional requests: a module serving ETags is useless if the validator
|
|
42
|
+
// cannot reach it, and the response side already passes ETag back.
|
|
43
|
+
"if-match",
|
|
44
|
+
"if-none-match",
|
|
45
|
+
"if-modified-since",
|
|
46
|
+
"if-unmodified-since",
|
|
47
|
+
"range",
|
|
48
|
+
// Carried so a module's own logs can tell browsers apart. It names the
|
|
49
|
+
// client, not the network path, so it is not part of the failure above.
|
|
50
|
+
"user-agent",
|
|
19
51
|
]);
|
|
52
|
+
/**
|
|
53
|
+
* Header names and prefixes that describe the network path a request took,
|
|
54
|
+
* not the request itself. Added by CDNs, load balancers and reverse proxies;
|
|
55
|
+
* never meaningful to a module, and actively harmful to replay into another
|
|
56
|
+
* CDN zone (see {@link FORWARDED_REQUEST_HEADERS}).
|
|
57
|
+
*
|
|
58
|
+
* Used ONLY to refuse a bad `extraRequestHeaders` entry. The allowlist alone
|
|
59
|
+
* already keeps every one of these out; this exists so an app cannot opt back
|
|
60
|
+
* into the exact failure the allowlist was introduced to end, and so it learns
|
|
61
|
+
* that at construction rather than from a Cloudflare error page in production.
|
|
62
|
+
*/
|
|
63
|
+
const PATH_DESCRIBING_HEADER_PREFIXES = ["cf-", "x-forwarded-", "akamai-", "fastly-", "x-amzn-"];
|
|
64
|
+
const PATH_DESCRIBING_HEADERS = new Set([
|
|
65
|
+
"cdn-loop",
|
|
66
|
+
"forwarded",
|
|
67
|
+
"true-client-ip",
|
|
68
|
+
"x-real-ip",
|
|
69
|
+
"via",
|
|
70
|
+
]);
|
|
71
|
+
function describesNetworkPath(name) {
|
|
72
|
+
const lower = name.toLowerCase();
|
|
73
|
+
return (PATH_DESCRIBING_HEADERS.has(lower) ||
|
|
74
|
+
PATH_DESCRIBING_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix)));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The effective request-header allowlist: the built-in set plus any opted-in
|
|
78
|
+
* extras, lowercased. Throws on an extra that describes the network path.
|
|
79
|
+
*/
|
|
80
|
+
function resolveForwardedHeaders(extra) {
|
|
81
|
+
if (extra === undefined || extra.length === 0)
|
|
82
|
+
return FORWARDED_REQUEST_HEADERS;
|
|
83
|
+
const resolved = new Set(FORWARDED_REQUEST_HEADERS);
|
|
84
|
+
for (const name of extra) {
|
|
85
|
+
const lower = name.toLowerCase();
|
|
86
|
+
if (describesNetworkPath(lower)) {
|
|
87
|
+
throw new TypeError(`extraRequestHeaders must not include ${lower}: it describes the network path, ` +
|
|
88
|
+
"and replaying such a header into the platform's CDN zone is what made every " +
|
|
89
|
+
"browser-side module call on a hosted tenant fail");
|
|
90
|
+
}
|
|
91
|
+
resolved.add(lower);
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
20
95
|
/**
|
|
21
96
|
* Build the catch-all route handlers for a module proxy.
|
|
22
97
|
*
|
|
@@ -36,6 +111,9 @@ const STRIPPED_REQUEST_HEADERS = new Set([
|
|
|
36
111
|
export function createModuleProxyRoutes(options) {
|
|
37
112
|
const doFetch = options.fetch ?? globalThis.fetch;
|
|
38
113
|
const base = platformBaseUrl({ apiUrl: options.apiUrl, appSlug: options.appSlug });
|
|
114
|
+
// Resolved once, at mount: a bad extra header fails when the route module is
|
|
115
|
+
// built, not on the first request that happens to carry it.
|
|
116
|
+
const forwarded = resolveForwardedHeaders(options.extraRequestHeaders);
|
|
39
117
|
async function forward(request, context) {
|
|
40
118
|
const credential = await options.readMemberCredential();
|
|
41
119
|
if (!credential)
|
|
@@ -45,7 +123,7 @@ export function createModuleProxyRoutes(options) {
|
|
|
45
123
|
target.search = new URL(request.url).search;
|
|
46
124
|
const headers = new Headers();
|
|
47
125
|
for (const [name, value] of request.headers) {
|
|
48
|
-
if (
|
|
126
|
+
if (forwarded.has(name.toLowerCase()))
|
|
49
127
|
headers.set(name, value);
|
|
50
128
|
}
|
|
51
129
|
headers.set("authorization", `Bearer ${credential}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module-proxy-routes.js","sourceRoot":"","sources":["../../src/next/module-proxy-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAoEjD;;;;;;;;GAQG;AACH,MAAM,wBAAwB,GAAG,IAAI,GAAG,CAAC;IACvC,QAAQ;IACR,MAAM;IACN,YAAY;IACZ,gBAAgB;IAChB,mBAAmB;IACnB,eAAe;IACf,iBAAiB;CAClB,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,uBAAuB,CAAC,OAAiC;IACvE,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IAClD,MAAM,IAAI,GAAG,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAEnF,KAAK,UAAU,OAAO,CAAC,OAAgB,EAAE,OAAqB;QAC5D,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,oBAAoB,EAAE,CAAC;QACxD,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAE5D,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC;QACtC,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACpD,MAAM,CAAC,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;QAE5C,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;QAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YAC5C,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;gBAAE,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAClF,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,UAAU,EAAE,CAAC,CAAC;QAErD,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC;QACtE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE;YACrC,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,OAAO;YACP,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;YACxC,MAAM,EAAE,MAAM;YACd,QAAQ,EAAE,QAAQ;SACiB,CAAC,CAAC;QAEvC,2EAA2E;QAC3E,sEAAsE;QACtE,6BAA6B;QAC7B,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC1C,GAAG,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAC/B,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QAC7B,GAAG,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;QAChC,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IAChF,CAAC;IAED,OAAO;QACL,GAAG,EAAE,OAAO;QACZ,IAAI,EAAE,OAAO;QACb,GAAG,EAAE,OAAO;QACZ,KAAK,EAAE,OAAO;QACd,MAAM,EAAE,OAAO;KAChB,CAAC;AACJ,CAAC","sourcesContent":["import { platformBaseUrl } from \"../base-url.js\";\n\n/**\n * The ONE server hop a browser module client talks through.\n *\n * 🔴 WHY THIS EXISTS AT ALL. A custom app keeps its member credential in an\n * HttpOnly cookie, which is the point of an HttpOnly cookie — script cannot\n * read it, so the browser cannot call the platform itself. That is the ONLY\n * thing the browser is missing. This supplies exactly that and forwards\n * everything else untouched.\n *\n * 🔴 WHY IT IS HERE RATHER THAN IN EACH APP. Every app that keeps its\n * credential in a cookie needs byte-identical code, and two lines of it are\n * invisible until they bite:\n *\n * - `duplex: \"half\"` is REQUIRED by undici whenever the body is a stream,\n * which it is for any upload. Without it the fetch throws before a byte\n * leaves, and the error names neither the upload nor the cause.\n * - `content-encoding` / `content-length` MUST be dropped from the response.\n * The body has already been decoded by the time it is re-sent, so a copied\n * `content-length` describes bytes that no longer exist and the request\n * hangs rather than failing.\n *\n * An app that writes this by hand gets to discover both. kaohsiung-association\n * carried a hand-written copy of this file, 98 lines, containing no knowledge\n * of any module — which is what made it boilerplate rather than app code.\n *\n * The alternative shape — a hand-written endpoint per operation — re-declares\n * each module's contract inside the app: one more place to keep in step every\n * time a module changes, and the generated client's types and errors are\n * discarded on the way through. With this mounted the browser uses the real\n * module client and this file never learns what any call means.\n *\n * 🔴 IT FORWARDS, IT DOES NOT DECIDE. Authorization remains the platform's and\n * the modules' answer on every request. This attaches a credential the member\n * already holds and grants nothing that credential does not carry.\n */\n\n/** Inputs for {@link createModuleProxyRoutes}. */\nexport interface ModuleProxyRoutesOptions {\n /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL`. */\n readonly apiUrl: string;\n /** The custom application's slug, typically `MIRRORSTACK_APP_SLUG`. */\n readonly appSlug: string;\n /**\n * Reads the member credential for the current request. Pass the\n * `readMemberCredential` from {@link createAuthRoutes}, so the proxy and\n * sign-in cannot disagree about where the session lives.\n */\n readonly readMemberCredential: () => Promise<string | null>;\n /** Fetch implementation for the upstream call. Defaults to `globalThis.fetch`. */\n readonly fetch?: typeof globalThis.fetch;\n}\n\n/** A Next.js App Router route module: one handler per forwarded method. */\nexport interface ModuleProxyRoutes {\n GET(request: Request, context: RouteContext): Promise<Response>;\n POST(request: Request, context: RouteContext): Promise<Response>;\n PUT(request: Request, context: RouteContext): Promise<Response>;\n PATCH(request: Request, context: RouteContext): Promise<Response>;\n DELETE(request: Request, context: RouteContext): Promise<Response>;\n}\n\n/** The second argument Next hands a catch-all route handler. */\nexport interface RouteContext {\n readonly params: Promise<{ path: string[] }>;\n}\n\n/**\n * Headers that must NOT be replayed upstream.\n *\n * `cookie` above all: the app's session cookie is its own, and forwarding it\n * hands a second credential to a service that never asked for one. The length\n * and hop-by-hop headers are re-derived by fetch, and a stale `content-length`\n * copied onto a re-encoded body is a hung request. `authorization` is dropped\n * because this hop sets it — a caller-supplied one must never survive.\n */\nconst STRIPPED_REQUEST_HEADERS = new Set([\n \"cookie\",\n \"host\",\n \"connection\",\n \"content-length\",\n \"transfer-encoding\",\n \"authorization\",\n \"accept-encoding\",\n]);\n\n/**\n * Build the catch-all route handlers for a module proxy.\n *\n * Mount at `app/api/mirrorstack/modules/[...path]/route.ts`:\n *\n * ```ts\n * export const runtime = \"nodejs\";\n * export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({\n * apiUrl: process.env.NEXT_PUBLIC_MIRRORSTACK_API!,\n * appSlug: process.env.NEXT_PUBLIC_APP_ID!,\n * readMemberCredential: auth.readMemberCredential,\n * });\n * ```\n *\n * The browser client's `baseUrl` is then that same mount path.\n */\nexport function createModuleProxyRoutes(options: ModuleProxyRoutesOptions): ModuleProxyRoutes {\n const doFetch = options.fetch ?? globalThis.fetch;\n const base = platformBaseUrl({ apiUrl: options.apiUrl, appSlug: options.appSlug });\n\n async function forward(request: Request, context: RouteContext): Promise<Response> {\n const credential = await options.readMemberCredential();\n if (!credential) return new Response(null, { status: 401 });\n\n const { path } = await context.params;\n const target = new URL(`${base}/${path.join(\"/\")}`);\n target.search = new URL(request.url).search;\n\n const headers = new Headers();\n for (const [name, value] of request.headers) {\n if (!STRIPPED_REQUEST_HEADERS.has(name.toLowerCase())) headers.set(name, value);\n }\n headers.set(\"authorization\", `Bearer ${credential}`);\n\n const hasBody = request.method !== \"GET\" && request.method !== \"HEAD\";\n const response = await doFetch(target, {\n method: request.method,\n headers,\n body: hasBody ? request.body : undefined,\n duplex: \"half\",\n redirect: \"manual\",\n } as RequestInit & { duplex: \"half\" });\n\n // The upstream status and body are returned as they are, so a module's own\n // \"too large\" or \"wrong type\" reaches the caller instead of a generic\n // failure nobody can act on.\n const out = new Headers(response.headers);\n out.delete(\"content-encoding\");\n out.delete(\"content-length\");\n out.delete(\"transfer-encoding\");\n return new Response(response.body, { status: response.status, headers: out });\n }\n\n return {\n GET: forward,\n POST: forward,\n PUT: forward,\n PATCH: forward,\n DELETE: forward,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"module-proxy-routes.js","sourceRoot":"","sources":["../../src/next/module-proxy-routes.ts"],"names":[],"mappings":"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","sourcesContent":["import { platformBaseUrl } from \"../base-url.js\";\n\n/**\n * The ONE server hop a browser module client talks through.\n *\n * 🔴 WHY THIS EXISTS AT ALL. A custom app keeps its member credential in an\n * HttpOnly cookie, which is the point of an HttpOnly cookie — script cannot\n * read it, so the browser cannot call the platform itself. That is the ONLY\n * thing the browser is missing. This supplies exactly that and forwards the\n * request otherwise unchanged — through a header ALLOWLIST, not untouched:\n * a hosted tenant's request arrives carrying its CDN edge's own headers, and\n * replaying those into the platform's CDN zone is what made every browser-side\n * module call on a hosted tenant fail. See {@link FORWARDED_REQUEST_HEADERS}.\n *\n * 🔴 WHY IT IS HERE RATHER THAN IN EACH APP. Every app that keeps its\n * credential in a cookie needs byte-identical code, and two lines of it are\n * invisible until they bite:\n *\n * - `duplex: \"half\"` is REQUIRED by undici whenever the body is a stream,\n * which it is for any upload. Without it the fetch throws before a byte\n * leaves, and the error names neither the upload nor the cause.\n * - `content-encoding` / `content-length` MUST be dropped from the response.\n * The body has already been decoded by the time it is re-sent, so a copied\n * `content-length` describes bytes that no longer exist and the request\n * hangs rather than failing.\n *\n * An app that writes this by hand gets to discover both. kaohsiung-association\n * carried a hand-written copy of this file, 98 lines, containing no knowledge\n * of any module — which is what made it boilerplate rather than app code.\n *\n * The alternative shape — a hand-written endpoint per operation — re-declares\n * each module's contract inside the app: one more place to keep in step every\n * time a module changes, and the generated client's types and errors are\n * discarded on the way through. With this mounted the browser uses the real\n * module client and this file never learns what any call means.\n *\n * 🔴 IT FORWARDS, IT DOES NOT DECIDE. Authorization remains the platform's and\n * the modules' answer on every request. This attaches a credential the member\n * already holds and grants nothing that credential does not carry.\n */\n\n/** Inputs for {@link createModuleProxyRoutes}. */\nexport interface ModuleProxyRoutesOptions {\n /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL`. */\n readonly apiUrl: string;\n /** The custom application's slug, typically `MIRRORSTACK_APP_SLUG`. */\n readonly appSlug: string;\n /**\n * Reads the member credential for the current request. Pass the\n * `readMemberCredential` from {@link createAuthRoutes}, so the proxy and\n * sign-in cannot disagree about where the session lives.\n */\n readonly readMemberCredential: () => Promise<string | null>;\n /**\n * Extra request headers to replay upstream, beyond the built-in allowlist.\n *\n * The allowlist is deliberately small, so an app or module that genuinely\n * needs a custom header from the browser names it here rather than having it\n * dropped silently. Matched case-insensitively.\n *\n * A name that describes the NETWORK PATH rather than the request is refused\n * at construction — `cf-*`, `x-forwarded-*`, `true-client-ip`, `forwarded`,\n * `cdn-loop` and friends are exactly what broke hosted tenants, and an\n * escape hatch that let one back in would reopen the hole it exists beside.\n */\n readonly extraRequestHeaders?: readonly string[];\n /** Fetch implementation for the upstream call. Defaults to `globalThis.fetch`. */\n readonly fetch?: typeof globalThis.fetch;\n}\n\n/** A Next.js App Router route module: one handler per forwarded method. */\nexport interface ModuleProxyRoutes {\n GET(request: Request, context: RouteContext): Promise<Response>;\n POST(request: Request, context: RouteContext): Promise<Response>;\n PUT(request: Request, context: RouteContext): Promise<Response>;\n PATCH(request: Request, context: RouteContext): Promise<Response>;\n DELETE(request: Request, context: RouteContext): Promise<Response>;\n}\n\n/** The second argument Next hands a catch-all route handler. */\nexport interface RouteContext {\n readonly params: Promise<{ path: string[] }>;\n}\n\n/**\n * Request headers this hop replays upstream. Everything else is dropped.\n *\n * 🔴 THIS IS AN ALLOWLIST, AND IT USED TO BE A DENYLIST. A denylist forwarded\n * every header the request arrived with, which on a hosted tenant means every\n * header the CDN edge added on the way in. Replaying Cloudflare's\n * `cf-connecting-ip` into the api.mirrorstack.ai zone made Cloudflare answer\n * the request itself — `403 text/html`, \"Error reference number: 1000\" — so\n * EVERY browser-side module call on a hosted tenant failed, and failed with a\n * page no module could interpret.\n *\n * Measured against the live API on 2026-09-12, 12 trials per condition:\n *\n * no edge headers → 401 JSON (0/12 gave 1000)\n * + cf-connecting-ip → 1000 (12/12)\n * + cf-connecting-ip and cdn-loop → 401 JSON (0/12)\n *\n * Two things follow, and the second is a trap. `cf-connecting-ip` is the\n * trigger — `x-forwarded-for`, `true-client-ip`, `cf-ray` and `cf-visitor`\n * each changed nothing. And `cdn-loop: cloudflare` SUPPRESSES it: Cloudflare\n * reads the pair as a legitimate CDN-to-CDN hop. So stripping `cdn-loop` while\n * still forwarding `cf-connecting-ip` would turn an intermittent failure into\n * a certain one. An allowlist removes both together and cannot get that wrong.\n *\n * The deeper reason for the inversion: this bug was an intermediary adding a\n * header nobody had thought of. A denylist can only ever exclude headers\n * someone thought of, so the next CDN in front of a tenant — `fastly-client-ip`,\n * `akamai-*`, `x-amzn-*` — would reproduce it exactly. Unknown does not travel.\n *\n * `cookie` and `authorization` are absent for their own reasons and would be\n * even if no CDN existed: the app's session cookie is its own, and forwarding\n * it hands a second credential to a service that never asked for one; this hop\n * sets `authorization` itself, so a caller-supplied one must never survive.\n * `content-length` and the hop-by-hop headers are re-derived by fetch.\n */\nconst FORWARDED_REQUEST_HEADERS = new Set([\n \"accept\",\n \"accept-language\",\n \"content-type\",\n // Conditional requests: a module serving ETags is useless if the validator\n // cannot reach it, and the response side already passes ETag back.\n \"if-match\",\n \"if-none-match\",\n \"if-modified-since\",\n \"if-unmodified-since\",\n \"range\",\n // Carried so a module's own logs can tell browsers apart. It names the\n // client, not the network path, so it is not part of the failure above.\n \"user-agent\",\n]);\n\n/**\n * Header names and prefixes that describe the network path a request took,\n * not the request itself. Added by CDNs, load balancers and reverse proxies;\n * never meaningful to a module, and actively harmful to replay into another\n * CDN zone (see {@link FORWARDED_REQUEST_HEADERS}).\n *\n * Used ONLY to refuse a bad `extraRequestHeaders` entry. The allowlist alone\n * already keeps every one of these out; this exists so an app cannot opt back\n * into the exact failure the allowlist was introduced to end, and so it learns\n * that at construction rather than from a Cloudflare error page in production.\n */\nconst PATH_DESCRIBING_HEADER_PREFIXES = [\"cf-\", \"x-forwarded-\", \"akamai-\", \"fastly-\", \"x-amzn-\"];\nconst PATH_DESCRIBING_HEADERS = new Set([\n \"cdn-loop\",\n \"forwarded\",\n \"true-client-ip\",\n \"x-real-ip\",\n \"via\",\n]);\n\nfunction describesNetworkPath(name: string): boolean {\n const lower = name.toLowerCase();\n return (\n PATH_DESCRIBING_HEADERS.has(lower) ||\n PATH_DESCRIBING_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))\n );\n}\n\n/**\n * The effective request-header allowlist: the built-in set plus any opted-in\n * extras, lowercased. Throws on an extra that describes the network path.\n */\nfunction resolveForwardedHeaders(extra: readonly string[] | undefined): ReadonlySet<string> {\n if (extra === undefined || extra.length === 0) return FORWARDED_REQUEST_HEADERS;\n const resolved = new Set(FORWARDED_REQUEST_HEADERS);\n for (const name of extra) {\n const lower = name.toLowerCase();\n if (describesNetworkPath(lower)) {\n throw new TypeError(\n `extraRequestHeaders must not include ${lower}: it describes the network path, ` +\n \"and replaying such a header into the platform's CDN zone is what made every \" +\n \"browser-side module call on a hosted tenant fail\",\n );\n }\n resolved.add(lower);\n }\n return resolved;\n}\n\n/**\n * Build the catch-all route handlers for a module proxy.\n *\n * Mount at `app/api/mirrorstack/modules/[...path]/route.ts`:\n *\n * ```ts\n * export const runtime = \"nodejs\";\n * export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({\n * apiUrl: process.env.NEXT_PUBLIC_MIRRORSTACK_API!,\n * appSlug: process.env.NEXT_PUBLIC_APP_ID!,\n * readMemberCredential: auth.readMemberCredential,\n * });\n * ```\n *\n * The browser client's `baseUrl` is then that same mount path.\n */\nexport function createModuleProxyRoutes(options: ModuleProxyRoutesOptions): ModuleProxyRoutes {\n const doFetch = options.fetch ?? globalThis.fetch;\n const base = platformBaseUrl({ apiUrl: options.apiUrl, appSlug: options.appSlug });\n // Resolved once, at mount: a bad extra header fails when the route module is\n // built, not on the first request that happens to carry it.\n const forwarded = resolveForwardedHeaders(options.extraRequestHeaders);\n\n async function forward(request: Request, context: RouteContext): Promise<Response> {\n const credential = await options.readMemberCredential();\n if (!credential) return new Response(null, { status: 401 });\n\n const { path } = await context.params;\n const target = new URL(`${base}/${path.join(\"/\")}`);\n target.search = new URL(request.url).search;\n\n const headers = new Headers();\n for (const [name, value] of request.headers) {\n if (forwarded.has(name.toLowerCase())) headers.set(name, value);\n }\n headers.set(\"authorization\", `Bearer ${credential}`);\n\n const hasBody = request.method !== \"GET\" && request.method !== \"HEAD\";\n const response = await doFetch(target, {\n method: request.method,\n headers,\n body: hasBody ? request.body : undefined,\n duplex: \"half\",\n redirect: \"manual\",\n } as RequestInit & { duplex: \"half\" });\n\n // The upstream status and body are returned as they are, so a module's own\n // \"too large\" or \"wrong type\" reaches the caller instead of a generic\n // failure nobody can act on.\n const out = new Headers(response.headers);\n out.delete(\"content-encoding\");\n out.delete(\"content-length\");\n out.delete(\"transfer-encoding\");\n return new Response(response.body, { status: response.status, headers: out });\n }\n\n return {\n GET: forward,\n POST: forward,\n PUT: forward,\n PATCH: forward,\n DELETE: forward,\n };\n}\n"]}
|