akanjs 3.1.1-rc.0 → 3.1.1-rc.2

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.
Files changed (89) hide show
  1. package/client/cookie.ts +80 -35
  2. package/client/route/LayoutDefinition.ts +3 -2
  3. package/client/route/RouteDefinition.ts +10 -5
  4. package/common/IpLiteral.ts +75 -0
  5. package/common/TrustedProxy.ts +100 -71
  6. package/common/appHosts.ts +32 -0
  7. package/common/authCookie.ts +8 -5
  8. package/common/authToken.ts +24 -6
  9. package/common/clientAddress.ts +22 -11
  10. package/common/cookieJar.ts +90 -0
  11. package/common/cookieSweep.ts +77 -0
  12. package/common/index.ts +13 -1
  13. package/common/remoteAuthCode.ts +55 -0
  14. package/common/sessionCookies.ts +43 -0
  15. package/common/types.ts +5 -0
  16. package/constant/constantRegistry.ts +33 -6
  17. package/constant/refNameClaims.ts +99 -0
  18. package/dictionary/base.dictionary.ts +4 -0
  19. package/document/databaseRegistry.ts +2 -2
  20. package/document/filterMeta.ts +2 -2
  21. package/fetch/client/fetchClient.ts +65 -25
  22. package/fetch/index.ts +1 -0
  23. package/fetch/renderScope.ts +54 -0
  24. package/fetch/requestStorage.ts +19 -9
  25. package/package.json +1 -1
  26. package/server/akanApp.ts +4 -2
  27. package/server/akanAppHeaders.ts +15 -13
  28. package/server/akanServer.ts +26 -4
  29. package/server/cachePolicy.ts +5 -3
  30. package/server/di/diLifecycle.ts +2 -0
  31. package/server/lifecycle/runtimeDir.ts +16 -1
  32. package/server/mcp/McpRouter.ts +54 -0
  33. package/server/ops/statusAccess.ts +59 -0
  34. package/server/routing/apiRouter.ts +71 -2
  35. package/server/routing/appWsData.ts +11 -6
  36. package/server/routing/routeHosts.ts +31 -0
  37. package/server/routing/soloAppRoutes.ts +7 -4
  38. package/server/rscClient.tsx +6 -4
  39. package/server/rscPagePrompts.ts +7 -3
  40. package/server/rscWorker.tsx +3 -1
  41. package/server/ssrFromRscRenderer.tsx +5 -3
  42. package/server/webRouter.ts +5 -2
  43. package/service/serve.ts +6 -3
  44. package/signal/endpointIdempotency.ts +3 -3
  45. package/signal/mcp/McpDocument.ts +25 -5
  46. package/signal/serializer/fetch.serializer.ts +1 -1
  47. package/signal/signalContext.ts +22 -10
  48. package/signal/slice.ts +19 -8
  49. package/test/ServedMcpCatalogue.ts +44 -0
  50. package/test/index.ts +9 -1
  51. package/test/signalTestRuntime.ts +11 -1
  52. package/test/testServer.ts +21 -0
  53. package/types/client/cookie.d.ts +7 -7
  54. package/types/common/IpLiteral.d.ts +6 -0
  55. package/types/common/TrustedProxy.d.ts +6 -0
  56. package/types/common/appHosts.d.ts +7 -0
  57. package/types/common/authCookie.d.ts +3 -2
  58. package/types/common/authToken.d.ts +13 -0
  59. package/types/common/clientAddress.d.ts +6 -2
  60. package/types/common/cookieJar.d.ts +22 -0
  61. package/types/common/cookieSweep.d.ts +12 -0
  62. package/types/common/index.d.ts +6 -1
  63. package/types/common/remoteAuthCode.d.ts +9 -0
  64. package/types/common/sessionCookies.d.ts +13 -0
  65. package/types/common/types.d.ts +5 -0
  66. package/types/constant/constantRegistry.d.ts +2 -1
  67. package/types/constant/refNameClaims.d.ts +12 -0
  68. package/types/dictionary/base.dictionary.d.ts +1 -1
  69. package/types/dictionary/dictionary.d.ts +8 -8
  70. package/types/document/filterMeta.d.ts +2 -2
  71. package/types/fetch/agentTurn.d.ts +1 -1
  72. package/types/fetch/index.d.ts +1 -0
  73. package/types/fetch/renderScope.d.ts +18 -0
  74. package/types/fetch/requestStorage.d.ts +10 -5
  75. package/types/server/akanServer.d.ts +10 -1
  76. package/types/server/cachePolicy.d.ts +1 -1
  77. package/types/server/mcp/McpRouter.d.ts +16 -1
  78. package/types/server/ops/statusAccess.d.ts +15 -0
  79. package/types/server/routing/appWsData.d.ts +4 -0
  80. package/types/server/routing/routeHosts.d.ts +4 -0
  81. package/types/server/routing/soloAppRoutes.d.ts +2 -1
  82. package/types/signal/mcp/McpDocument.d.ts +8 -0
  83. package/types/signal/signalContext.d.ts +6 -4
  84. package/types/signal/slice.d.ts +2 -1
  85. package/types/test/ServedMcpCatalogue.d.ts +15 -0
  86. package/types/test/index.d.ts +3 -2
  87. package/types/test/signalTestRuntime.d.ts +8 -1
  88. package/types/test/testServer.d.ts +15 -0
  89. package/ui/System/Client.tsx +2 -3
package/client/cookie.ts CHANGED
@@ -2,14 +2,19 @@ import { getEnv } from "akanjs/base";
2
2
  import {
3
3
  authTokenKey,
4
4
  bearerTokenOf,
5
+ type CookieEntry,
6
+ CookieJar,
7
+ CookieSweep,
5
8
  decodeJwtPayload,
6
9
  isOwnAuthToken,
7
10
  Logger,
8
11
  legacyAuthTokenKey,
9
- readAuthToken,
12
+ readSessionToken,
13
+ retiredAuthTokenKeys,
14
+ SessionCookies,
10
15
  } from "akanjs/common";
11
16
  import type { Account } from "akanjs/fetch";
12
- import { parseCookieHeader, cookies as serverCookies, headers as serverHeaders } from "akanjs/fetch";
17
+ import { cookieJar as serverCookieJar, headers as serverHeaders } from "akanjs/fetch";
13
18
  import { secretStorage } from "./storage";
14
19
  import { fetch } from "./useClient";
15
20
 
@@ -24,26 +29,35 @@ const isCsrClient = () => {
24
29
  return env.side === "client" && env.renderMode === "csr";
25
30
  };
26
31
 
27
- const cookieJarKey = "akan:cookies";
28
- const pageKeepsCookies = () => (globalThis as { location?: Location }).location?.protocol !== "app:";
29
- const readCookieJar = (): Record<string, string> => {
32
+ const storedCookiesKey = "akan:cookies";
33
+ const pageLocation = () => (globalThis as { location?: Location }).location;
34
+ const pageKeepsCookies = () => pageLocation()?.protocol !== "app:";
35
+ const readStoredCookies = (): Record<string, string> => {
30
36
  try {
31
- return JSON.parse(localStorage.getItem(cookieJarKey) ?? "{}") as Record<string, string>;
37
+ return JSON.parse(localStorage.getItem(storedCookiesKey) ?? "{}") as Record<string, string>;
32
38
  } catch {
33
39
  return {};
34
40
  }
35
41
  };
36
- const writeCookieJar = (jar: Record<string, string>) => {
37
- localStorage.setItem(cookieJarKey, JSON.stringify(jar));
42
+ const writeStoredCookies = (stored: Record<string, string>) => {
43
+ localStorage.setItem(storedCookiesKey, JSON.stringify(stored));
38
44
  };
39
45
 
40
- export const cookies = (): Map<string, { name: string; value: string }> => {
41
- if (getEnv().side === "server") return serverCookies();
46
+ /** Every cookie this page or request holds, a name sent more than once included. */
47
+ export const cookieJar = (): CookieJar => {
48
+ if (getEnv().side === "server") return serverCookieJar();
42
49
  if (!pageKeepsCookies())
43
- return new Map(Object.entries(readCookieJar()).map(([name, value]) => [name, { name, value }]));
44
- return parseCookieHeader(document.cookie);
50
+ return CookieJar.parse(
51
+ Object.entries(readStoredCookies())
52
+ .map(([name, value]) => `${name}=${value}`)
53
+ .join("; "),
54
+ );
55
+ return CookieJar.parse(document.cookie);
45
56
  };
46
57
 
58
+ /** Names held exactly once; a duplicated name is left out. */
59
+ export const cookies = (): Map<string, CookieEntry> => cookieJar().toMap();
60
+
47
61
  export const setCookie = (
48
62
  key: string,
49
63
  value: string,
@@ -51,7 +65,7 @@ export const setCookie = (
51
65
  ) => {
52
66
  if (getEnv().side === "server") return;
53
67
  if (!pageKeepsCookies()) {
54
- writeCookieJar({ ...readCookieJar(), [key]: value });
68
+ writeStoredCookies({ ...readStoredCookies(), [key]: value });
55
69
  return;
56
70
  }
57
71
  const pathAttr = path ? `; path=${path}` : "";
@@ -60,19 +74,21 @@ export const setCookie = (
60
74
  document.cookie = `${key}=${value}${pathAttr}; SameSite=${sameSite}${secureAttr}`;
61
75
  };
62
76
 
63
- /** Reads through `cookies()` on both sides: a hand-rolled `split("=")` cuts base64 padding and skips the `j:` form. */
64
- export const getCookie = (key: string): string | undefined => cookies().get(key)?.value;
77
+ /** `undefined` for a name held more than once, as for one never set. */
78
+ export const getCookie = (key: string): string | undefined => cookieJar().read(key).value;
65
79
 
66
80
  export const removeCookie = (key: string, options: { path: string } = { path: "/" }) => {
67
81
 
68
82
  if (getEnv().side === "server") return;
69
83
  if (!pageKeepsCookies()) {
70
- const { [key]: _removed, ...rest } = readCookieJar();
71
- writeCookieJar(rest);
84
+ const { [key]: _removed, ...rest } = readStoredCookies();
85
+ writeStoredCookies(rest);
72
86
  return;
73
87
  }
74
88
 
75
- document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${options.path};`;
89
+ const secureAttr = /^__(Host|Secure)-/.test(key) ? " Secure;" : "";
90
+
91
+ document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${options.path};${secureAttr}`;
76
92
  };
77
93
  export const headers = (): Map<string, string> => {
78
94
  if (getEnv().side !== "server") return new Map();
@@ -95,17 +111,15 @@ const sentAuthToken = (): string | undefined => {
95
111
  };
96
112
 
97
113
  /** The auth token this app holds: on a CSR client the one `fetch` sends first, since a native shell's page keeps no
98
- * session cookie; then the cookie jar, under the app-scoped key or a legacy global one. */
114
+ * session cookie; then the app-scoped cookie, unless it is held more than once. */
99
115
  export const getAuthToken = (): string | undefined =>
100
- (isCsrClient() ? sentAuthToken() : undefined) ?? readAuthToken(getCookie);
116
+ (isCsrClient() ? sentAuthToken() : undefined) ?? readSessionToken(cookieJar()).token;
101
117
 
102
118
  /** The auth token this app holds in client storage — the OS credential store in a native app, localStorage elsewhere. */
103
- export const getStoredAuthToken = async (): Promise<string | undefined> => {
104
- const scoped = await secretStorage.getItem(authTokenKey());
105
- if (scoped) return scoped;
106
- const legacy = await secretStorage.getItem(legacyAuthTokenKey);
107
- return legacy && isOwnAuthToken(legacy) ? legacy : undefined;
108
- };
119
+ export const getStoredAuthToken = async (): Promise<string | undefined> =>
120
+ (await secretStorage.getItem(storedAuthTokenKey())) ?? undefined;
121
+
122
+ const storedAuthTokenKey = () => `${legacyAuthTokenKey}:${getEnv().appName}`;
109
123
 
110
124
  /** The current JWT's account when minted for this app and environment. Reads only the two credentials the server
111
125
  * honours — the app-scoped auth cookie (a CSR client's bearer token) and `Authorization: Bearer` — so the two never
@@ -136,9 +150,9 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
136
150
  fetch.setJwt(jwt);
137
151
 
138
152
  if (!isCsrClient()) setCookie(authTokenKey(), jwt);
139
- void secretStorage.setItem(authTokenKey(), jwt);
153
+ void secretStorage.setItem(storedAuthTokenKey(), jwt);
140
154
 
141
- removeCookie(legacyAuthTokenKey);
155
+ for (const key of retiredAuthTokenKeys()) removeCookie(key);
142
156
  void secretStorage.removeItem(legacyAuthTokenKey);
143
157
  announceAuth();
144
158
  };
@@ -146,25 +160,56 @@ export const setAuth = ({ jwt }: SetAuthOption) => {
146
160
  interface InitAuthOption {
147
161
  jwt?: string;
148
162
  }
163
+
164
+ const sweepDocumentCookies = (names: string[]) => {
165
+ const { hostname = "", pathname = "/" } = pageLocation() ?? {};
166
+
167
+ for (const entry of CookieSweep.setCookies({ names, host: hostname, path: pathname })) document.cookie = entry;
168
+ };
169
+
170
+ const readCookieSession = (): string | null | undefined => {
171
+ const first = readSessionToken(cookieJar());
172
+ if (!first.duplicate) return first.token;
173
+ SessionCookies.report([first.duplicate], pageLocation()?.host ?? "");
174
+ sweepDocumentCookies([first.duplicate.name]);
175
+ const second = readSessionToken(cookieJar());
176
+ return second.duplicate ? null : second.token;
177
+ };
178
+
179
+ const expireRetiredCookies = () => {
180
+ const jar = cookieJar();
181
+ for (const key of retiredAuthTokenKeys()) if (jar.has(key)) removeCookie(key);
182
+ };
183
+
149
184
  export const initAuth = ({ jwt }: InitAuthOption = {}) => {
150
- const stored = getAuthToken();
151
- const token = jwt ?? stored;
185
+ expireRetiredCookies();
186
+ const sent = jwt ?? (isCsrClient() ? sentAuthToken() : undefined);
187
+ const token = sent ?? readCookieSession();
188
+ if (token === null) {
189
+ fetch.setJwt(null);
190
+ return;
191
+ }
152
192
  if (token && !isOwnAuthToken(token)) {
153
193
 
154
194
  Logger.warn("JWT ignored: it was minted for another app or environment");
155
- if (token === stored) resetAuth();
195
+ if (!jwt) resetAuth();
156
196
  return;
157
197
  }
158
- if (token) setAuth({ jwt: token });
198
+ if (token && sent) setAuth({ jwt: token });
199
+ else if (token) {
200
+
201
+ fetch.setJwt(token);
202
+ announceAuth();
203
+ }
159
204
 
160
- Logger.verbose(`JWT ${token ? "restored from cookie" : "not found in cookie"}`);
205
+ Logger.verbose(`JWT ${token ? "restored" : "not found"}`);
161
206
  };
162
207
 
163
208
  export const resetAuth = () => {
164
209
  fetch.setJwt(null);
165
210
  removeCookie(authTokenKey());
166
- removeCookie(legacyAuthTokenKey);
167
- void secretStorage.removeItem(authTokenKey());
211
+ for (const key of retiredAuthTokenKeys()) removeCookie(key);
212
+ void secretStorage.removeItem(storedAuthTokenKey());
168
213
  void secretStorage.removeItem(legacyAuthTokenKey);
169
214
  announceAuth();
170
215
  };
@@ -1,3 +1,4 @@
1
+ import { RenderScope } from "akanjs/fetch";
1
2
  import type { ReactNode } from "react";
2
3
  import type { LayoutErrorRender, LayoutModule, LayoutNotFoundRender, PageModule } from "../csrTypes";
3
4
  import { type RouteArgsShape, RouteDefinition, type RouteKind } from "./RouteDefinition";
@@ -27,12 +28,12 @@ export class LayoutDefinition<Args extends RouteArgsShape = Record<never, never>
27
28
  }
28
29
 
29
30
  notFound(render: LayoutNotFoundRender) {
30
- this.#notFound = render;
31
+ this.#notFound = (props) => RenderScope.run(() => render(props));
31
32
  return this;
32
33
  }
33
34
 
34
35
  error(render: LayoutErrorRender) {
35
- this.#error = render;
36
+ this.#error = (props) => RenderScope.run(() => render(props));
36
37
  return this;
37
38
  }
38
39
 
@@ -8,6 +8,7 @@ import {
8
8
  } from "akanjs/base";
9
9
  import { parseAkanI18nEnv } from "akanjs/common";
10
10
  import { deserialize } from "akanjs/constant";
11
+ import { RenderScope } from "akanjs/fetch";
11
12
  import type { ReactNode } from "react";
12
13
  import type { Head, LayoutModule, PageConfig, PageModule, RouteGuardProps } from "../csrTypes";
13
14
  import { AkanNotFoundError } from "../router";
@@ -145,12 +146,15 @@ export abstract class RouteDefinition<
145
146
  ...(head === undefined
146
147
  ? {}
147
148
  : typeof head === "function"
148
- ? { generateHead: async (props: RouteRenderProps) => await head(this.#argsOf(props) as never) }
149
+ ? {
150
+ generateHead: async (props: RouteRenderProps) =>
151
+ await RenderScope.run(() => head(this.#argsOf(props) as never)),
152
+ }
149
153
  : { head }),
150
154
  ...(loading
151
155
  ? {
152
156
  Loading: ((props: RouteRenderProps) =>
153
- loading(this.#argsOf({ ...props, searchParams: {} }) as never)) as never,
157
+ RenderScope.run(() => loading(this.#argsOf({ ...props, searchParams: {} }) as never))) as never,
154
158
  }
155
159
  : {}),
156
160
  };
@@ -167,7 +171,8 @@ export abstract class RouteDefinition<
167
171
  }
168
172
 
169
173
  #guardRouteOf(guard: (args: never) => void | Promise<void>) {
170
- const route = async (props: RouteGuardProps) => await guard(this.#guardArgsOf(props) as never);
174
+ const route = async (props: RouteGuardProps) =>
175
+ await RenderScope.run(() => guard(this.#guardArgsOf(props) as never));
171
176
  Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
172
177
  return route;
173
178
  }
@@ -178,10 +183,10 @@ export abstract class RouteDefinition<
178
183
 
179
184
  const route = async (props: RouteRenderProps) => {
180
185
  const args = this.renderArgsOf(this.#argsOf(props));
181
- if (!(args instanceof Promise)) return await render(args as never);
186
+ if (!(args instanceof Promise)) return await RenderScope.run(() => render(args as never));
182
187
  const path = Translator.getRenderPath();
183
188
  const resolved = await args;
184
- return await Translator.renderAt(path, () => render(resolved as never));
189
+ return await Translator.renderAt(path, () => RenderScope.run(() => render(resolved as never)));
185
190
  };
186
191
  Object.defineProperty(route, routeArgsMarker, { value: this.args.map(({ kind, name }) => ({ kind, name })) });
187
192
  this.#renderRoute = { render, argCount: this.args.length, route };
@@ -0,0 +1,75 @@
1
+ export class IpLiteral {
2
+
3
+ static normalize(address: string): string {
4
+ const trimmed = address.trim();
5
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed);
6
+ return mapped?.[1] ?? trimmed;
7
+ }
8
+
9
+ static parse(entry: string): string | null {
10
+ const trimmed = entry.trim();
11
+ const bracketed = /^\[([^\]]*:[^\]]*)\](?::\d{1,5})?$/.exec(trimmed);
12
+ const v4WithPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d{1,5}$/.exec(trimmed);
13
+ const [address = "", zone, ...rest] = (bracketed?.[1] ?? v4WithPort?.[1] ?? trimmed).split("%");
14
+ if (rest.length) return null;
15
+ if (zone !== undefined && !(address.includes(":") && /^[A-Za-z0-9_.-]{1,64}$/.test(zone))) return null;
16
+ const bytes = IpLiteral.bytes(IpLiteral.normalize(address));
17
+
18
+ return bytes ? IpLiteral.#format(bytes) : null;
19
+ }
20
+
21
+ static bytes(address: string): Uint8Array | null {
22
+ if (address.includes(".") && !address.includes(":")) {
23
+ const parts = address.split(".");
24
+ if (parts.length !== 4) return null;
25
+ const bytes = new Uint8Array(4);
26
+ for (const [idx, part] of parts.entries()) {
27
+ const value = Number(part);
28
+
29
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(part) || value > 255) return null;
30
+ bytes[idx] = value;
31
+ }
32
+ return bytes;
33
+ }
34
+ return address.includes(":") ? IpLiteral.#ipv6Bytes(address) : null;
35
+ }
36
+
37
+ static #ipv6Bytes(address: string): Uint8Array | null {
38
+
39
+ const bare = address.split("%")[0] ?? "";
40
+ const halves = bare.split("::");
41
+ if (halves.length > 2) return null;
42
+ const groupsOf = (text: string) => (text ? text.split(":").filter((group) => group !== "") : []);
43
+ const head = groupsOf(halves[0] ?? "");
44
+ const tail = halves.length === 2 ? groupsOf(halves[1] ?? "") : [];
45
+ const filled = halves.length === 2 ? 8 - head.length - tail.length : 0;
46
+ if (filled < 0 || head.length + tail.length + filled !== 8) return null;
47
+ const groups = [...head, ...new Array(filled).fill("0"), ...tail];
48
+ const bytes = new Uint8Array(16);
49
+ for (const [idx, group] of groups.entries()) {
50
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
51
+ const value = Number.parseInt(group, 16);
52
+ bytes[idx * 2] = value >> 8;
53
+ bytes[idx * 2 + 1] = value & 0xff;
54
+ }
55
+ return bytes;
56
+ }
57
+
58
+ static #format(bytes: Uint8Array): string {
59
+ if (bytes.length === 4) return bytes.join(".");
60
+ if (bytes.subarray(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff)
61
+ return bytes.subarray(12).join(".");
62
+ const groups = Array.from({ length: 8 }, (_, idx) => ((bytes[idx * 2] ?? 0) << 8) | (bytes[idx * 2 + 1] ?? 0));
63
+ let runStart = -1;
64
+ let runLength = 1;
65
+ for (let start = 0; start < groups.length; ) {
66
+ let end = start;
67
+ while (end < groups.length && groups[end] === 0) end += 1;
68
+ if (end - start > runLength) [runStart, runLength] = [start, end - start];
69
+ start = Math.max(end, start + 1);
70
+ }
71
+ const hex = groups.map((group) => group.toString(16));
72
+ if (runStart < 0) return hex.join(":");
73
+ return `${hex.slice(0, runStart).join(":")}::${hex.slice(runStart + runLength).join(":")}`;
74
+ }
75
+ }
@@ -1,39 +1,80 @@
1
1
  import { clientAddressFromHeaders, normalizeIpAddress } from "./clientAddress";
2
+ import { IpLiteral } from "./IpLiteral";
3
+ import { Logger } from "./Logger";
4
+
5
+ interface Cidr {
6
+ bytes: Uint8Array;
7
+ bits: number;
8
+ }
9
+
10
+ interface TrustPolicy {
11
+ trustAll: boolean;
12
+ privateRanges: boolean;
13
+ strict: boolean;
14
+ cidrs: Cidr[];
15
+ }
16
+
17
+ export interface ResolvedClient {
18
+ address: string | null;
19
+ /** Read from a trusted hop's forwarded headers rather than taken from the socket peer. */
20
+ forwarded: boolean;
21
+ }
2
22
 
3
23
  export class TrustedProxy {
4
- static #cidrs: { bytes: Uint8Array; bits: number }[] | null = null;
5
- static #trustAll: boolean | null = null;
24
+ static #policy: TrustPolicy | null = null;
6
25
 
7
26
  static readonly #verdicts = new Map<string, boolean>();
8
27
  static readonly #verdictLimit = 1024;
28
+ static #warnedIgnoredPeer = false;
9
29
 
10
30
  static configure(list: string | null | undefined) {
31
+ TrustedProxy.reset();
32
+ TrustedProxy.#policy = TrustedProxy.#policyOf(list);
33
+ }
34
+
35
+ static reset() {
36
+ TrustedProxy.#policy = null;
11
37
  TrustedProxy.#verdicts.clear();
12
- TrustedProxy.#trustAll = list?.trim() === "*";
13
- TrustedProxy.#cidrs = (list ?? "")
38
+ TrustedProxy.#warnedIgnoredPeer = false;
39
+ }
40
+
41
+ static #load(): TrustPolicy {
42
+ TrustedProxy.#policy ??= TrustedProxy.#policyOf(process.env.AKAN_TRUSTED_PROXIES);
43
+ return TrustedProxy.#policy;
44
+ }
45
+
46
+ static #policyOf(list: string | null | undefined): TrustPolicy {
47
+ const entries = (list ?? "")
14
48
  .split(",")
15
49
  .map((entry) => entry.trim())
16
- .filter((entry) => entry && entry !== "*")
50
+ .filter((entry) => entry);
51
+ const keywords = new Set(entries.map((entry) => entry.toLowerCase()));
52
+ const cidrs = entries
53
+ .filter((entry) => !["*", "none", "private"].includes(entry.toLowerCase()))
17
54
  .flatMap((entry) => {
18
55
  const parsed = TrustedProxy.#parseCidr(entry);
56
+ if (!parsed)
57
+ new Logger("TrustedProxy").warn(
58
+ `AKAN_TRUSTED_PROXIES entry "${entry}" is neither an address nor a CIDR; ignored`,
59
+ );
19
60
  return parsed ? [parsed] : [];
20
61
  });
21
- }
22
-
23
- static reset() {
24
- TrustedProxy.#cidrs = null;
25
- TrustedProxy.#trustAll = null;
26
- TrustedProxy.#verdicts.clear();
27
- }
28
-
29
- static #load() {
30
- if (TrustedProxy.#cidrs === null) TrustedProxy.configure(process.env.AKAN_TRUSTED_PROXIES);
62
+ const trustAll = keywords.has("*");
63
+ if (trustAll && entries.length > 1)
64
+ new Logger("TrustedProxy").warn(
65
+ `AKAN_TRUSTED_PROXIES lists "*", which trusts every peer, so its other entries change nothing`,
66
+ );
67
+ return {
68
+ trustAll,
69
+ privateRanges: !entries.length || keywords.has("private"),
70
+ strict: entries.length > 0 && !trustAll,
71
+ cidrs,
72
+ };
31
73
  }
32
74
 
33
75
  static isTrusted(address: string | null | undefined): boolean {
34
76
  if (!address) return false;
35
- TrustedProxy.#load();
36
- if (TrustedProxy.#trustAll) return true;
77
+ if (TrustedProxy.#load().trustAll) return true;
37
78
  const known = TrustedProxy.#verdicts.get(address);
38
79
  if (known !== undefined) return known;
39
80
  const verdict = TrustedProxy.#judge(address);
@@ -43,26 +84,45 @@ export class TrustedProxy {
43
84
  }
44
85
 
45
86
  static #judge(address: string): boolean {
46
- const bytes = TrustedProxy.#toBytes(normalizeIpAddress(address));
87
+ const bytes = IpLiteral.bytes(normalizeIpAddress(address));
47
88
  if (!bytes) return false;
48
- if (TrustedProxy.#isLocalRange(bytes)) return true;
49
- return (TrustedProxy.#cidrs ?? []).some((cidr) => TrustedProxy.#inRange(bytes, cidr));
89
+ const policy = TrustedProxy.#load();
90
+ if (TrustedProxy.#isLoopback(bytes)) return true;
91
+ if (policy.privateRanges && TrustedProxy.#isPrivateRange(bytes)) return true;
92
+ return policy.cidrs.some((cidr) => TrustedProxy.#inRange(bytes, cidr));
50
93
  }
51
94
 
52
95
  /** A `null` peer is a unix socket, which only a local proxy opens, so its headers count; `undefined` is not. */
53
96
  static clientAddress(headers: Headers, peerAddress: string | null | undefined): string | null {
97
+ return TrustedProxy.resolve(headers, peerAddress).address;
98
+ }
99
+
100
+ static resolve(headers: Headers, peerAddress: string | null | undefined): ResolvedClient {
54
101
  if (peerAddress === null || TrustedProxy.isTrusted(peerAddress)) {
55
- const recorded = clientAddressFromHeaders(headers);
56
- if (recorded) return recorded;
57
- }
58
- return peerAddress ? normalizeIpAddress(peerAddress) : null;
102
+
103
+ const judge = TrustedProxy.#load().strict ? (address: string) => TrustedProxy.isTrusted(address) : undefined;
104
+ const recorded = clientAddressFromHeaders(headers, judge);
105
+ if (recorded) return { address: recorded, forwarded: true };
106
+ } else if (peerAddress) TrustedProxy.#noteIgnoredPeer(headers, peerAddress);
107
+ return { address: peerAddress ? normalizeIpAddress(peerAddress) : null, forwarded: false };
108
+ }
109
+
110
+ static #noteIgnoredPeer(headers: Headers, peerAddress: string) {
111
+ if (TrustedProxy.#warnedIgnoredPeer || !TrustedProxy.#load().strict) return;
112
+ if (!headers.has("x-forwarded-for") && !headers.has("x-real-ip")) return;
113
+ const bytes = IpLiteral.bytes(normalizeIpAddress(peerAddress));
114
+ if (!bytes || !TrustedProxy.#isPrivateRange(bytes)) return;
115
+ TrustedProxy.#warnedIgnoredPeer = true;
116
+ new Logger("TrustedProxy").warn(
117
+ `Forwarded headers from ${normalizeIpAddress(peerAddress)} are ignored: AKAN_TRUSTED_PROXIES does not list it. If it is your proxy, add its address or a CIDR that holds only your proxies.`,
118
+ );
59
119
  }
60
120
 
61
121
  /** For refusing an outbound fetch to a caller-chosen name: judges the address alone, never the proxy list. */
62
122
  static isPrivateAddress(address: string): boolean {
63
- const bytes = TrustedProxy.#toBytes(normalizeIpAddress(address));
123
+ const bytes = IpLiteral.bytes(normalizeIpAddress(address));
64
124
  if (!bytes) return true;
65
- if (TrustedProxy.#isLocalRange(bytes)) return true;
125
+ if (TrustedProxy.#isLoopback(bytes) || TrustedProxy.#isPrivateRange(bytes)) return true;
66
126
  if (bytes.length === 4) {
67
127
  const [a = 0, b = 0] = bytes;
68
128
  return a === 0 || (a === 100 && b >= 64 && b <= 127) || a >= 224;
@@ -70,31 +130,36 @@ export class TrustedProxy {
70
130
  return bytes.every((byte) => byte === 0) || bytes[0] === 0xff;
71
131
  }
72
132
 
73
- /** `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, and their IPv6 counterparts `::1`, `fc00::/7`, `fe80::/10`. */
74
- static #isLocalRange(bytes: Uint8Array): boolean {
133
+ static #isLoopback(bytes: Uint8Array): boolean {
134
+ if (bytes.length === 4) return bytes[0] === 127;
135
+ return bytes.every((byte, idx) => (idx === 15 ? byte === 1 : byte === 0));
136
+ }
137
+
138
+ /** `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, and their IPv6 counterparts `fc00::/7`, `fe80::/10`. */
139
+ static #isPrivateRange(bytes: Uint8Array): boolean {
75
140
  if (bytes.length === 4) {
76
141
  const [a = 0, b = 0] = bytes;
77
- if (a === 10 || a === 127) return true;
142
+ if (a === 10) return true;
78
143
  if (a === 192 && b === 168) return true;
79
144
  if (a === 172 && b >= 16 && b <= 31) return true;
80
145
  return a === 169 && b === 254;
81
146
  }
82
147
  const [first = 0, second = 0] = bytes;
83
148
  if ((first & 0xfe) === 0xfc) return true;
84
- if (first === 0xfe && (second & 0xc0) === 0x80) return true;
85
- return bytes.every((byte, idx) => (idx === 15 ? byte === 1 : byte === 0));
149
+ return first === 0xfe && (second & 0xc0) === 0x80;
86
150
  }
87
151
 
88
- static #parseCidr(entry: string): { bytes: Uint8Array; bits: number } | null {
89
- const [address, prefix] = entry.split("/");
90
- const bytes = TrustedProxy.#toBytes(address ?? "");
152
+ static #parseCidr(entry: string): Cidr | null {
153
+ const [address, prefix, ...rest] = entry.split("/");
154
+ if (rest.length || (prefix !== undefined && !/^\d{1,3}$/.test(prefix))) return null;
155
+ const bytes = IpLiteral.bytes(normalizeIpAddress(address ?? ""));
91
156
  if (!bytes) return null;
92
157
  const bits = prefix === undefined ? bytes.length * 8 : Number(prefix);
93
158
  if (!Number.isInteger(bits) || bits < 0 || bits > bytes.length * 8) return null;
94
159
  return { bytes, bits };
95
160
  }
96
161
 
97
- static #inRange(bytes: Uint8Array, cidr: { bytes: Uint8Array; bits: number }): boolean {
162
+ static #inRange(bytes: Uint8Array, cidr: Cidr): boolean {
98
163
  if (bytes.length !== cidr.bytes.length) return false;
99
164
  const wholeBytes = cidr.bits >> 3;
100
165
  for (let idx = 0; idx < wholeBytes; idx += 1) if (bytes[idx] !== cidr.bytes[idx]) return false;
@@ -103,40 +168,4 @@ export class TrustedProxy {
103
168
  const mask = 0xff << (8 - restBits);
104
169
  return ((bytes[wholeBytes] ?? 0) & mask) === ((cidr.bytes[wholeBytes] ?? 0) & mask);
105
170
  }
106
-
107
- static #toBytes(address: string): Uint8Array | null {
108
- if (address.includes(".") && !address.includes(":")) {
109
- const parts = address.split(".");
110
- if (parts.length !== 4) return null;
111
- const bytes = new Uint8Array(4);
112
- for (const [idx, part] of parts.entries()) {
113
- const value = Number(part);
114
- if (!/^\d{1,3}$/.test(part) || value > 255) return null;
115
- bytes[idx] = value;
116
- }
117
- return bytes;
118
- }
119
- return address.includes(":") ? TrustedProxy.#ipv6ToBytes(address) : null;
120
- }
121
-
122
- static #ipv6ToBytes(address: string): Uint8Array | null {
123
-
124
- const bare = address.split("%")[0] ?? "";
125
- const halves = bare.split("::");
126
- if (halves.length > 2) return null;
127
- const groupsOf = (text: string) => (text ? text.split(":").filter((group) => group !== "") : []);
128
- const head = groupsOf(halves[0] ?? "");
129
- const tail = halves.length === 2 ? groupsOf(halves[1] ?? "") : [];
130
- const filled = halves.length === 2 ? 8 - head.length - tail.length : 0;
131
- if (filled < 0 || head.length + tail.length + filled !== 8) return null;
132
- const groups = [...head, ...new Array(filled).fill("0"), ...tail];
133
- const bytes = new Uint8Array(16);
134
- for (const [idx, group] of groups.entries()) {
135
- if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
136
- const value = Number.parseInt(group, 16);
137
- bytes[idx * 2] = value >> 8;
138
- bytes[idx * 2 + 1] = value & 0xff;
139
- }
140
- return bytes;
141
- }
142
171
  }
@@ -0,0 +1,32 @@
1
+ import { getEnv } from "akanjs/base";
2
+
3
+ export class AppHosts {
4
+ static readonly #declared = new Set<string>();
5
+
6
+ static declare(hosts: Iterable<string>) {
7
+ for (const host of hosts) {
8
+ const name = AppHosts.nameOf(host);
9
+ if (name) AppHosts.#declared.add(name);
10
+ }
11
+ }
12
+
13
+ static reset() {
14
+ AppHosts.#declared.clear();
15
+ }
16
+
17
+ static allows(host: string, extra: Iterable<string> = []): boolean {
18
+ const name = AppHosts.nameOf(host);
19
+ if (!name) return false;
20
+ const { appName, environment, serveDomain } = getEnv();
21
+ if (name === AppHosts.nameOf(`${appName}-${environment}.${serveDomain}`) || AppHosts.#declared.has(name))
22
+ return true;
23
+ const listed = [...(process.env.AKAN_ALLOWED_HOSTS ?? "").split(","), ...extra];
24
+ return listed.some((entry) => AppHosts.nameOf(entry) === name);
25
+ }
26
+
27
+ static nameOf(host: string): string | null {
28
+ const url = URL.parse(`http://${host.trim()}`);
29
+ if (!url || url.href !== `${url.origin}/`) return null;
30
+ return url.hostname.replace(/\.$/, "") || null;
31
+ }
32
+ }
@@ -1,13 +1,16 @@
1
1
 
2
2
 
3
- /** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
3
+ /** The pre-scoping key. Never read or written; sign-in and sign-out still delete it from jars written before. */
4
4
  export const legacyAuthTokenKey = "jwt";
5
5
 
6
- /** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
7
- export const isAuthTokenKey = (key: string): boolean =>
8
- key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
6
+ /** Matches every spelling, bare, app-scoped or `__Host-`, so a signout can sweep a jar written under any of them. */
7
+ export const isAuthTokenKey = (key: string): boolean => {
8
+ const name = key.startsWith("__Host-") ? key.slice("__Host-".length) : key;
9
+ return name === legacyAuthTokenKey || name.startsWith(`${legacyAuthTokenKey}:`);
10
+ };
9
11
 
10
- const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
12
+ const authCookiePattern = new RegExp(`(?:^|;\\s*)(?:__Host-)?${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
11
13
 
14
+ /** @deprecated Unused by the framework; parse the header with `CookieJar.parse` and test its `names()`. */
12
15
  export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
13
16
  !!cookieHeader && authCookiePattern.test(cookieHeader);