nucleus-core-ts 0.10.151 → 0.10.152

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.
@@ -1,3 +1,5 @@
1
+ import { pathMatchesTemplate } from '../../Shared/routeMatch';
2
+ import { type RoutePathRefusal } from '../../Shared/routePath';
1
3
  import type { ManifestRoute, ProxyAuthorizeConfig, ProxyLogger } from './types';
2
4
  /**
3
5
  * Reverse-proxy claim-gate. Enforces the discovered `{serviceId}.{tag}.{op}` claims
@@ -13,8 +15,13 @@ export type { ManifestRoute, ProxyAuthorizeConfig } from './types';
13
15
  export declare function verifyJwtHS256(token: string, secret: string): Record<string, unknown> | null;
14
16
  /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */
15
17
  export declare function claimSatisfies(userClaims: string[], required: string): boolean;
16
- /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */
17
- export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
18
+ /** The route rule the IDP's discovery applies too: one copy, in src/Shared/routeMatch.ts. */
19
+ export { pathMatchesTemplate };
20
+ /**
21
+ * The action a request requires, or null when no route maps it. Matched on the path the
22
+ * backend routes (`%2F` decoded), the raw spelling only when that maps nothing:
23
+ * src/Shared/routeMatch.ts `findRouteAction`.
24
+ */
18
25
  export declare function findRequiredAction(manifest: ManifestRoute[], method: string, path: string): string | null;
19
26
  export interface AccessDecision {
20
27
  decision: 'allow' | 'deny';
@@ -23,6 +30,9 @@ export interface AccessDecision {
23
30
  /** When a ClaimGuard caused the denial, the guard's action (e.g. `token_spent_today`) so the
24
31
  * client can render a human-readable message. */
25
32
  guard?: string;
33
+ /** Set with reason `ambiguous-path`: which reading of the path differs between routers.
34
+ * The proxy answers it 400, in audit mode too (routePathOf in src/Shared/routePath.ts). */
35
+ pathRefusal?: RoutePathRefusal;
26
36
  }
27
37
  /**
28
38
  * Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
@@ -10,6 +10,8 @@ function _define_property(obj, key, value) {
10
10
  return obj;
11
11
  }
12
12
  import { createHmac, timingSafeEqual } from 'node:crypto';
13
+ import { findRouteAction, pathMatchesTemplate } from '../../Shared/routeMatch';
14
+ import { routePathOf } from '../../Shared/routePath';
13
15
  /** HS256-verify a JWT and return its payload, or null if invalid/expired/tampered. */ export function verifyJwtHS256(token, secret) {
14
16
  const parts = token.split('.');
15
17
  if (parts.length !== 3) return null;
@@ -29,40 +31,13 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
29
31
  /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */ export function claimSatisfies(userClaims, required) {
30
32
  return userClaims.some((uc)=>required === uc || required.startsWith(`${uc}.`));
31
33
  }
32
- /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */ export function pathMatchesTemplate(template, concrete) {
33
- const t = template.replace(/\/+$/, '').split('/');
34
- const c = concrete.replace(/\/+$/, '').split('/');
35
- if (t.length !== c.length) return false;
36
- for(let i = 0; i < t.length; i++){
37
- const seg = t[i];
38
- if (seg === undefined) return false;
39
- if (seg.startsWith('{') && seg.endsWith('}')) {
40
- // A path parameter must bind a REAL, non-empty segment. An empty segment (from a
41
- // `//` double slash) is never a valid binding — reject it so the route stays
42
- // unmapped (fail-closed) instead of over-matching the parameterized route's claim.
43
- if (c[i] === '' || c[i] === undefined) return false;
44
- continue;
45
- }
46
- if (seg !== c[i]) return false;
47
- }
48
- return true;
49
- }
50
- /** Count full-segment `{param}` wildcards — fewer = more specific (more literal segments). */ function templateSpecificity(template) {
51
- return template.replace(/\/+$/, '').split('/').filter((s)=>s.startsWith('{') && s.endsWith('}')).length;
52
- }
53
- export function findRequiredAction(manifest, method, path) {
54
- const m = method.toUpperCase();
55
- const matches = manifest.filter((r)=>r.method === m && pathMatchesTemplate(r.path, path));
56
- if (matches.length === 0) return null;
57
- // MOST-SPECIFIC wins (never first-match): a literal route (`/files/admin`) must beat a
58
- // parameterized sibling (`/files/{id}`) regardless of manifest order — else a request to
59
- // an admin endpoint resolves to the weaker by-id claim (privilege escalation). Rank by
60
- // fewest `{param}` segments, deterministic tiebreak on path for equal specificity.
61
- matches.sort((a, b)=>{
62
- const d = templateSpecificity(a.path) - templateSpecificity(b.path);
63
- return d !== 0 ? d : a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
64
- });
65
- return matches[0]?.action ?? null;
34
+ /** The route rule the IDP's discovery applies too: one copy, in src/Shared/routeMatch.ts. */ export { pathMatchesTemplate };
35
+ /**
36
+ * The action a request requires, or null when no route maps it. Matched on the path the
37
+ * backend routes (`%2F` decoded), the raw spelling only when that maps nothing:
38
+ * src/Shared/routeMatch.ts `findRouteAction`.
39
+ */ export function findRequiredAction(manifest, method, path) {
40
+ return findRouteAction(manifest, method, path);
66
41
  }
67
42
  function matchesGlob(path, patterns) {
68
43
  if (!patterns?.length) return false;
@@ -86,11 +61,26 @@ function matchesGlob(path, patterns) {
86
61
  * this stays mode-agnostic and easy to test.
87
62
  */ export function evaluateAccess(args) {
88
63
  const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole, suppressedClaims, firedGuards } = args;
89
- if (matchesGlob(path, publicPaths)) return {
90
- decision: 'allow',
91
- reason: 'public',
92
- action: null
93
- };
64
+ // The route is decided on the path the backend routes, never the raw bytes: `%2F` is a
65
+ // separator to uvicorn, so a raw match let `/api%2Fv1/x` off its template and onto
66
+ // onUnmapped. A path that means different routes to different readers is refused outright,
67
+ // before any other rule, whoever asks (src/Shared/routePath.ts).
68
+ const routed = routePathOf(path);
69
+ if (!routed.ok) {
70
+ return {
71
+ decision: 'deny',
72
+ reason: 'ambiguous-path',
73
+ action: null,
74
+ pathRefusal: routed.refusal
75
+ };
76
+ }
77
+ if (matchesGlob(routed.path, publicPaths)) {
78
+ return {
79
+ decision: 'allow',
80
+ reason: 'public',
81
+ action: null
82
+ };
83
+ }
94
84
  const action = findRequiredAction(manifest, method, path);
95
85
  if (action === null) {
96
86
  // Fail CLOSED when the manifest has never loaded — an empty manifest from an unreachable
@@ -1,6 +1,7 @@
1
1
  import { evaluateAccess, ManifestCache, RoleClaimsCache, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
2
2
  import { canBufferForRetry, timeoutForBody } from './largeBody';
3
3
  import { failureOfStatus, refreshFailureResponse } from './refreshOutcome';
4
+ import { cookieWithRefreshedToken } from './retryCookie';
4
5
  import { createProxyLogger, matchPath, parseCookies, redactSecrets, rewritePath } from './utils';
5
6
  async function fetchSuppressedClaims(url, cookieHeader, logger) {
6
7
  const empty = {
@@ -367,6 +368,23 @@ export function createHttpProxyHandler(config) {
367
368
  firedGuards: suppression?.firedGuards
368
369
  });
369
370
  if (decision.decision === 'allow') return null;
371
+ // A path the gate cannot map the way the backend will is refused in either mode: under
372
+ // audit the gate still names guards (claim_guard below is enforced there), and a path
373
+ // that reads as another route downstream would carry a request past one.
374
+ if (decision.reason === 'ambiguous-path') {
375
+ logger.warn(`[authz] 400 ambiguous-path ${req.method} ${path} (${decision.pathRefusal})`);
376
+ return new Response(JSON.stringify({
377
+ success: false,
378
+ message: 'Bad Request',
379
+ reason: 'ambiguous_path',
380
+ detail: decision.pathRefusal ?? null
381
+ }), {
382
+ status: 400,
383
+ headers: {
384
+ 'content-type': 'application/json'
385
+ }
386
+ });
387
+ }
370
388
  const detail = `${req.method} ${path} (${decision.reason}${decision.guard ? ` guard=${decision.guard}` : ''}${decision.action ? ` need ${decision.action}` : ''})`;
371
389
  // A ClaimGuard block is a HARD, live condition: enforce it even while the general gate is
372
390
  // auditing. Every other would-block (missing-claim, unmapped) still only logs in audit mode.
@@ -737,6 +755,11 @@ export function createHttpProxyHandler(config) {
737
755
  const refreshResult = attempt.result;
738
756
  const retryHeaders = buildHeaders(req, target, clientIp);
739
757
  injectToken(retryHeaders, refreshResult.accessToken, tokenHeaderName);
758
+ // One token per retry: the cookie that carried the stale one carries the new one,
759
+ // or a backend that compares the two refuses the retry (retryCookie.ts).
760
+ const retryCookie = cookieWithRefreshedToken(retryHeaders.get('cookie'), accessCookieName, accessToken, refreshResult.accessToken);
761
+ if (retryCookie) retryHeaders.set('cookie', retryCookie);
762
+ else retryHeaders.delete('cookie');
740
763
  logger.info(`[TokenRefresh] Retrying ${req.method} ${path} with new token`);
741
764
  const retryResponse = await forwardRequest(finalUrl, req.method, retryHeaders, bodyForRetry ? new Response(bodyForRetry).body : null, timeout, followRedirects, req.signal);
742
765
  const retryDuration = performance.now() - startTime;
@@ -1,3 +1,11 @@
1
+ export type { RoutePath, RoutePathRefusal } from '../../Shared/routePath';
2
+ /**
3
+ * The path a backend routes and the claim gate's own decision, for a consumer that gates
4
+ * or previews requests the way this proxy does (a portal's spend guard, an IDP's
5
+ * enforcement preview) instead of keeping a copy that drifts.
6
+ */
7
+ export { asgiRoutePath, routePathOf } from '../../Shared/routePath';
8
+ export { claimSatisfies, evaluateAccess, findRequiredAction, pathMatchesTemplate, resolveClaimsFromRoles, } from './authz';
1
9
  export { createHttpProxyHandler } from './httpProxy';
2
10
  export type { RefreshFailure } from './refreshOutcome';
3
11
  export { failureOfStatus, refreshFailureResponse, sessionEndCookies } from './refreshOutcome';
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The path a backend routes and the claim gate's own decision, for a consumer that gates
3
+ * or previews requests the way this proxy does (a portal's spend guard, an IDP's
4
+ * enforcement preview) instead of keeping a copy that drifts.
5
+ */ export { asgiRoutePath, routePathOf } from '../../Shared/routePath';
6
+ export { claimSatisfies, evaluateAccess, findRequiredAction, pathMatchesTemplate, resolveClaimsFromRoles } from './authz';
1
7
  export { createHttpProxyHandler } from './httpProxy';
2
8
  export { failureOfStatus, refreshFailureResponse, sessionEndCookies } from './refreshOutcome';
3
9
  export { createProxyServer, defaultProxyConfig } from './server';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The Cookie header a request is RETRIED with after a refresh.
3
+ *
4
+ * WHY THIS EXISTS. A 401 is answered by refreshing the token and sending the request
5
+ * again with the new one as its Bearer. Until 0.10.152 the retry copied the caller's
6
+ * Cookie header as it arrived, so the backend got the NEW token in `Authorization` and
7
+ * the OLD one in the `access_token` cookie. A backend that checks both refuses that pair
8
+ * outright (the Vorion LLM answers CALLER_PROOF_CONFLICT: "the Bearer token and the
9
+ * access-token cookie differ"), so the retry failed every time and every visit after
10
+ * an idle half hour spent a refresh-token rotation for nothing.
11
+ *
12
+ * The retry now carries one token: every cookie that held the token the first attempt
13
+ * sent — the access cookie by name, and any fallback cookie it was read from — holds the
14
+ * refreshed one. Nothing is added: a request that sent no access cookie retries without
15
+ * one, the Bearer carries the token. Every other cookie, and the order, is kept.
16
+ */
17
+ export declare function cookieWithRefreshedToken(cookieHeader: string | null, accessCookieName: string, staleToken: string | undefined, freshToken: string): string | null;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The Cookie header a request is RETRIED with after a refresh.
3
+ *
4
+ * WHY THIS EXISTS. A 401 is answered by refreshing the token and sending the request
5
+ * again with the new one as its Bearer. Until 0.10.152 the retry copied the caller's
6
+ * Cookie header as it arrived, so the backend got the NEW token in `Authorization` and
7
+ * the OLD one in the `access_token` cookie. A backend that checks both refuses that pair
8
+ * outright (the Vorion LLM answers CALLER_PROOF_CONFLICT: "the Bearer token and the
9
+ * access-token cookie differ"), so the retry failed every time and every visit after
10
+ * an idle half hour spent a refresh-token rotation for nothing.
11
+ *
12
+ * The retry now carries one token: every cookie that held the token the first attempt
13
+ * sent — the access cookie by name, and any fallback cookie it was read from — holds the
14
+ * refreshed one. Nothing is added: a request that sent no access cookie retries without
15
+ * one, the Bearer carries the token. Every other cookie, and the order, is kept.
16
+ */ export function cookieWithRefreshedToken(cookieHeader, accessCookieName, staleToken, freshToken) {
17
+ if (!cookieHeader) return cookieHeader;
18
+ return cookieHeader.split(';').map((part)=>{
19
+ const pair = part.trim();
20
+ const eq = pair.indexOf('=');
21
+ if (eq <= 0) return pair;
22
+ const name = pair.slice(0, eq).trim();
23
+ const value = pair.slice(eq + 1).trim();
24
+ const stale = name === accessCookieName || staleToken !== undefined && value === staleToken;
25
+ return stale ? `${name}=${freshToken}` : pair;
26
+ }).filter((pair)=>pair.length > 0).join('; ');
27
+ }
@@ -8,6 +8,7 @@
8
8
  * be found by the `{serviceId}.` prefix during reconcile. `serviceId` therefore MUST
9
9
  * NOT be an HTTP-method word (get/post/put/patch/delete/toggle/bulk).
10
10
  */
11
+ import { pathMatchesTemplate } from '../../../Shared/routeMatch';
11
12
  import { type FetchOpenApiOptions } from './fetch';
12
13
  export interface ClaimSpec {
13
14
  action: string;
@@ -37,15 +38,15 @@ export interface ParseOptions {
37
38
  * operationIds (method+path is unique within an OpenAPI doc).
38
39
  */
39
40
  export declare function parseOpenApiToClaims(serviceId: string, doc: OpenApiDoc, opts?: ParseOptions): ClaimSpec[];
40
- /** True when a concrete request path matches a templated OpenAPI path. */
41
- export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
41
+ /** True when a concrete request path matches a templated OpenAPI path (src/Shared/routeMatch.ts). */
42
+ export { pathMatchesTemplate };
42
43
  /**
43
44
  * Given the claim specs for a service and an incoming (method, concrete-path),
44
45
  * return the required claim action, or null when no route matches (the caller
45
- * decides allow/deny for unmapped routes). When several templates match, the
46
- * MOST-SPECIFIC one wins (never first-declared): a literal route must beat a
47
- * parameterized sibling so `/files/admin` requires its own claim, not `/files/{id}`'s.
48
- * This MUST stay in lockstep with the proxy's findRequiredAction (Client/Proxy/authz.ts).
46
+ * decides allow/deny for unmapped routes). The most specific template wins, and the
47
+ * path is matched as the backend routes it (`%2F` decoded) before its raw spelling.
48
+ * It is the proxy's own rule (Client/Proxy/authz.ts `findRequiredAction`): both call
49
+ * `findRouteAction` in src/Shared/routeMatch.ts, so they cannot drift apart.
49
50
  */
50
51
  export declare function matchRouteToAction(specs: ClaimSpec[], method: string, concretePath: string): string | null;
51
52
  /** An existing claim row as seen during reconcile. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Which discovered route a request is: the one rule the proxy's claim gate
3
+ * (`src/Client/Proxy/authz.ts` `findRequiredAction`) and the IDP's discovery
4
+ * (`src/Services/Authorization/EndpointDiscovery` `matchRouteToAction`) both apply.
5
+ * They used to carry a copy each, kept "in lockstep" by a comment; one copy is how
6
+ * they stay the same.
7
+ */
8
+ /** One route: an OpenAPI method + templated path, and the claim action it requires. */
9
+ export interface RouteRow {
10
+ action: string;
11
+ path: string;
12
+ method: string;
13
+ }
14
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */
15
+ export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
16
+ /**
17
+ * The action a request requires, or null when no route maps it.
18
+ *
19
+ * Matched first on the path the backend routes ({@link asgiRoutePath}: `%2F` is a
20
+ * separator there), so an encoded separator can no longer move a request off its
21
+ * template. Only when that path maps nothing is the raw path tried: a Starlette
22
+ * `{p:path}` parameter takes `a%2Fb` as one value, and the manifest, built from
23
+ * `/openapi.json` where FastAPI strips the converter, shows it as one `{p}` segment —
24
+ * matching the raw spelling keeps such a request on the route it always mapped to.
25
+ * The raw path can therefore only ADD a requirement the decoded one lacked.
26
+ */
27
+ export declare function findRouteAction<R extends RouteRow>(routes: readonly R[], method: string, path: string): string | null;
@@ -0,0 +1,53 @@
1
+ import { asgiRoutePath } from './routePath';
2
+ const isParameter = (segment)=>segment.startsWith('{') && segment.endsWith('}');
3
+ const segmentsOf = (path)=>path.replace(/\/+$/, '').split('/');
4
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */ export function pathMatchesTemplate(template, concrete) {
5
+ const t = segmentsOf(template);
6
+ const c = segmentsOf(concrete);
7
+ if (t.length !== c.length) return false;
8
+ for(let i = 0; i < t.length; i++){
9
+ const seg = t[i];
10
+ if (seg === undefined) return false;
11
+ if (isParameter(seg)) {
12
+ // A path parameter must bind a REAL, non-empty segment. An empty segment (from a
13
+ // `//` double slash) is never a valid binding — reject it so the route stays
14
+ // unmapped (fail-closed) instead of over-matching the parameterized route's claim.
15
+ if (c[i] === '' || c[i] === undefined) return false;
16
+ continue;
17
+ }
18
+ if (seg !== c[i]) return false;
19
+ }
20
+ return true;
21
+ }
22
+ /** Count full-segment `{param}` wildcards — fewer = more specific (more literal segments). */ function templateSpecificity(template) {
23
+ return segmentsOf(template).filter(isParameter).length;
24
+ }
25
+ function mostSpecificAction(routes, method, path) {
26
+ const matches = routes.filter((r)=>r.method === method && pathMatchesTemplate(r.path, path));
27
+ // MOST-SPECIFIC wins (never first-match): a literal route (`/files/admin`) must beat a
28
+ // parameterized sibling (`/files/{id}`) regardless of manifest order — else a request to
29
+ // an admin endpoint resolves to the weaker by-id claim (privilege escalation). Rank by
30
+ // fewest `{param}` segments, deterministic tiebreak on path for equal specificity.
31
+ matches.sort((a, b)=>{
32
+ const d = templateSpecificity(a.path) - templateSpecificity(b.path);
33
+ return d !== 0 ? d : a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
34
+ });
35
+ return matches[0]?.action ?? null;
36
+ }
37
+ /**
38
+ * The action a request requires, or null when no route maps it.
39
+ *
40
+ * Matched first on the path the backend routes ({@link asgiRoutePath}: `%2F` is a
41
+ * separator there), so an encoded separator can no longer move a request off its
42
+ * template. Only when that path maps nothing is the raw path tried: a Starlette
43
+ * `{p:path}` parameter takes `a%2Fb` as one value, and the manifest, built from
44
+ * `/openapi.json` where FastAPI strips the converter, shows it as one `{p}` segment —
45
+ * matching the raw spelling keeps such a request on the route it always mapped to.
46
+ * The raw path can therefore only ADD a requirement the decoded one lacked.
47
+ */ export function findRouteAction(routes, method, path) {
48
+ const m = method.toUpperCase();
49
+ const routed = asgiRoutePath(path);
50
+ const action = mostSpecificAction(routes, m, routed);
51
+ if (action !== null || routed === path) return action;
52
+ return mostSpecificAction(routes, m, path);
53
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The path a backend router sees, computed from the raw path a proxy forwards.
3
+ *
4
+ * WHY THIS EXISTS. A proxy that gates by route must match the request against the
5
+ * route the BACKEND will serve, not against the bytes on the wire. The services behind
6
+ * the claim gate are FastAPI on uvicorn, and uvicorn percent-decodes the raw path once
7
+ * (`urllib.parse.unquote`) before Starlette picks a route: `/api%2Fv1/x` is served by the
8
+ * `/api/v1/x` route. Matching the raw path made `%2F` a way off a guarded template and
9
+ * onto `onUnmapped` (nucleus-core-ts 0.10.151 and earlier).
10
+ *
11
+ * The WHATWG URL parser has already resolved `.`, `..` and their `%2e` spellings before
12
+ * a proxy reads `url.pathname`. What survives it are encoded separators: `%2F`, `%5C`,
13
+ * and dot segments that only appear once those are decoded (`x%2F..%2Fy`).
14
+ *
15
+ * Two answers:
16
+ * - {@link asgiRoutePath}: the one decode uvicorn applies, nothing more;
17
+ * - {@link routePathOf}: that decode, or a refusal when the path means different routes
18
+ * to different readers. Starlette does not resolve a decoded `..`, some proxies and
19
+ * frameworks do; `\` is a separator to some servers and text to others; and a path
20
+ * whose second decode adds a separator is one hop away from another route. None of
21
+ * those shapes is sent by a client that is not looking for the difference, so they
22
+ * are refused rather than guessed.
23
+ *
24
+ * Lives in `src/Shared` because the proxy entry (`nucleus-core-ts/proxy`) is transpiled
25
+ * file by file and may import only its own folder and this one (nucleus.config.ts).
26
+ */
27
+ /**
28
+ * One pass of CPython 3.11 `urllib.parse.unquote`, the decode uvicorn applies:
29
+ * - a run of valid `%XX` escapes becomes its bytes read as UTF-8; bytes that are not
30
+ * valid UTF-8 become U+FFFD (`errors='replace'`), and a leading BOM is kept;
31
+ * - `%` not followed by two hex digits stays literal;
32
+ * - one pass only: `%252F` becomes the text `%2F`, never a separator.
33
+ * It never throws, where `decodeURIComponent` does on a malformed escape.
34
+ */
35
+ export declare function asgiRoutePath(rawPath: string): string;
36
+ /** Why a path was refused: which reading would differ between routers. */
37
+ export type RoutePathRefusal = 'dot-segment' | 'backslash' | 'double-encoded';
38
+ export type RoutePath = {
39
+ ok: true;
40
+ path: string;
41
+ } | {
42
+ ok: false;
43
+ refusal: RoutePathRefusal;
44
+ };
45
+ /**
46
+ * The path to match routes against, or the reason it cannot be matched safely.
47
+ *
48
+ * `ok` carries {@link asgiRoutePath} of the input. Refused:
49
+ * - `dot-segment`: the decode produced a `.` or `..` segment;
50
+ * - `backslash`: the decode produced a `\`;
51
+ * - `double-encoded`: decoding the result again (up to eight more times) would add a
52
+ * `/`, a `\` or a dot segment — `%252F`, `%255C`, `%252E%252E` between slashes.
53
+ * A further decode that changes only text inside a segment (`%2541` → `%41` → `A`)
54
+ * moves no route boundary and is not refused.
55
+ */
56
+ export declare function routePathOf(rawPath: string): RoutePath;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The path a backend router sees, computed from the raw path a proxy forwards.
3
+ *
4
+ * WHY THIS EXISTS. A proxy that gates by route must match the request against the
5
+ * route the BACKEND will serve, not against the bytes on the wire. The services behind
6
+ * the claim gate are FastAPI on uvicorn, and uvicorn percent-decodes the raw path once
7
+ * (`urllib.parse.unquote`) before Starlette picks a route: `/api%2Fv1/x` is served by the
8
+ * `/api/v1/x` route. Matching the raw path made `%2F` a way off a guarded template and
9
+ * onto `onUnmapped` (nucleus-core-ts 0.10.151 and earlier).
10
+ *
11
+ * The WHATWG URL parser has already resolved `.`, `..` and their `%2e` spellings before
12
+ * a proxy reads `url.pathname`. What survives it are encoded separators: `%2F`, `%5C`,
13
+ * and dot segments that only appear once those are decoded (`x%2F..%2Fy`).
14
+ *
15
+ * Two answers:
16
+ * - {@link asgiRoutePath}: the one decode uvicorn applies, nothing more;
17
+ * - {@link routePathOf}: that decode, or a refusal when the path means different routes
18
+ * to different readers. Starlette does not resolve a decoded `..`, some proxies and
19
+ * frameworks do; `\` is a separator to some servers and text to others; and a path
20
+ * whose second decode adds a separator is one hop away from another route. None of
21
+ * those shapes is sent by a client that is not looking for the difference, so they
22
+ * are refused rather than guessed.
23
+ *
24
+ * Lives in `src/Shared` because the proxy entry (`nucleus-core-ts/proxy`) is transpiled
25
+ * file by file and may import only its own folder and this one (nucleus.config.ts).
26
+ */ const escapeRun = /(?:%[0-9A-Fa-f]{2})+/g;
27
+ const utf8 = new TextDecoder('utf-8', {
28
+ ignoreBOM: true
29
+ });
30
+ /** How many further decodes are checked for a separator that would appear. */ const DECODE_ROUNDS = 8;
31
+ /**
32
+ * One pass of CPython 3.11 `urllib.parse.unquote`, the decode uvicorn applies:
33
+ * - a run of valid `%XX` escapes becomes its bytes read as UTF-8; bytes that are not
34
+ * valid UTF-8 become U+FFFD (`errors='replace'`), and a leading BOM is kept;
35
+ * - `%` not followed by two hex digits stays literal;
36
+ * - one pass only: `%252F` becomes the text `%2F`, never a separator.
37
+ * It never throws, where `decodeURIComponent` does on a malformed escape.
38
+ */ export function asgiRoutePath(rawPath) {
39
+ return rawPath.replace(escapeRun, (run)=>{
40
+ const bytes = new Uint8Array(run.length / 3);
41
+ for(let i = 0; i < bytes.length; i++){
42
+ bytes[i] = Number.parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
43
+ }
44
+ return utf8.decode(bytes);
45
+ });
46
+ }
47
+ function shapeOf(path) {
48
+ const segments = path.split('/');
49
+ return {
50
+ segments: segments.length,
51
+ dots: segments.some((segment)=>segment === '.' || segment === '..'),
52
+ backslash: path.includes('\\')
53
+ };
54
+ }
55
+ /**
56
+ * The path to match routes against, or the reason it cannot be matched safely.
57
+ *
58
+ * `ok` carries {@link asgiRoutePath} of the input. Refused:
59
+ * - `dot-segment`: the decode produced a `.` or `..` segment;
60
+ * - `backslash`: the decode produced a `\`;
61
+ * - `double-encoded`: decoding the result again (up to eight more times) would add a
62
+ * `/`, a `\` or a dot segment — `%252F`, `%255C`, `%252E%252E` between slashes.
63
+ * A further decode that changes only text inside a segment (`%2541` → `%41` → `A`)
64
+ * moves no route boundary and is not refused.
65
+ */ export function routePathOf(rawPath) {
66
+ const routed = asgiRoutePath(rawPath);
67
+ if (routed === rawPath) return {
68
+ ok: true,
69
+ path: routed
70
+ };
71
+ const shape = shapeOf(routed);
72
+ if (shape.dots) return {
73
+ ok: false,
74
+ refusal: 'dot-segment'
75
+ };
76
+ if (shape.backslash) return {
77
+ ok: false,
78
+ refusal: 'backslash'
79
+ };
80
+ let current = routed;
81
+ for(let round = 0; round < DECODE_ROUNDS; round++){
82
+ const next = asgiRoutePath(current);
83
+ if (next === current) break;
84
+ const later = shapeOf(next);
85
+ if (later.segments !== shape.segments || later.dots || later.backslash) {
86
+ return {
87
+ ok: false,
88
+ refusal: 'double-encoded'
89
+ };
90
+ }
91
+ current = next;
92
+ }
93
+ return {
94
+ ok: true,
95
+ path: routed
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.10.151",
3
+ "version": "0.10.152",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",