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.
- package/client/cookie.ts +80 -35
- package/client/route/LayoutDefinition.ts +3 -2
- package/client/route/RouteDefinition.ts +10 -5
- package/common/IpLiteral.ts +75 -0
- package/common/TrustedProxy.ts +100 -71
- package/common/appHosts.ts +32 -0
- package/common/authCookie.ts +8 -5
- package/common/authToken.ts +24 -6
- package/common/clientAddress.ts +22 -11
- package/common/cookieJar.ts +90 -0
- package/common/cookieSweep.ts +77 -0
- package/common/index.ts +13 -1
- package/common/remoteAuthCode.ts +55 -0
- package/common/sessionCookies.ts +43 -0
- package/common/types.ts +5 -0
- package/constant/constantRegistry.ts +33 -6
- package/constant/refNameClaims.ts +99 -0
- package/dictionary/base.dictionary.ts +4 -0
- package/document/databaseRegistry.ts +2 -2
- package/document/filterMeta.ts +2 -2
- package/fetch/client/fetchClient.ts +65 -25
- package/fetch/index.ts +1 -0
- package/fetch/renderScope.ts +54 -0
- package/fetch/requestStorage.ts +19 -9
- package/package.json +1 -1
- package/server/akanApp.ts +4 -2
- package/server/akanAppHeaders.ts +15 -13
- package/server/akanServer.ts +26 -4
- package/server/cachePolicy.ts +5 -3
- package/server/di/diLifecycle.ts +2 -0
- package/server/lifecycle/runtimeDir.ts +16 -1
- package/server/mcp/McpRouter.ts +54 -0
- package/server/ops/statusAccess.ts +59 -0
- package/server/routing/apiRouter.ts +71 -2
- package/server/routing/appWsData.ts +11 -6
- package/server/routing/routeHosts.ts +31 -0
- package/server/routing/soloAppRoutes.ts +7 -4
- package/server/rscClient.tsx +6 -4
- package/server/rscPagePrompts.ts +7 -3
- package/server/rscWorker.tsx +3 -1
- package/server/ssrFromRscRenderer.tsx +5 -3
- package/server/webRouter.ts +5 -2
- package/service/serve.ts +6 -3
- package/signal/endpointIdempotency.ts +3 -3
- package/signal/mcp/McpDocument.ts +25 -5
- package/signal/serializer/fetch.serializer.ts +1 -1
- package/signal/signalContext.ts +22 -10
- package/signal/slice.ts +19 -8
- package/test/ServedMcpCatalogue.ts +44 -0
- package/test/index.ts +9 -1
- package/test/signalTestRuntime.ts +11 -1
- package/test/testServer.ts +21 -0
- package/types/client/cookie.d.ts +7 -7
- package/types/common/IpLiteral.d.ts +6 -0
- package/types/common/TrustedProxy.d.ts +6 -0
- package/types/common/appHosts.d.ts +7 -0
- package/types/common/authCookie.d.ts +3 -2
- package/types/common/authToken.d.ts +13 -0
- package/types/common/clientAddress.d.ts +6 -2
- package/types/common/cookieJar.d.ts +22 -0
- package/types/common/cookieSweep.d.ts +12 -0
- package/types/common/index.d.ts +6 -1
- package/types/common/remoteAuthCode.d.ts +9 -0
- package/types/common/sessionCookies.d.ts +13 -0
- package/types/common/types.d.ts +5 -0
- package/types/constant/constantRegistry.d.ts +2 -1
- package/types/constant/refNameClaims.d.ts +12 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/filterMeta.d.ts +2 -2
- package/types/fetch/agentTurn.d.ts +1 -1
- package/types/fetch/index.d.ts +1 -0
- package/types/fetch/renderScope.d.ts +18 -0
- package/types/fetch/requestStorage.d.ts +10 -5
- package/types/server/akanServer.d.ts +10 -1
- package/types/server/cachePolicy.d.ts +1 -1
- package/types/server/mcp/McpRouter.d.ts +16 -1
- package/types/server/ops/statusAccess.d.ts +15 -0
- package/types/server/routing/appWsData.d.ts +4 -0
- package/types/server/routing/routeHosts.d.ts +4 -0
- package/types/server/routing/soloAppRoutes.d.ts +2 -1
- package/types/signal/mcp/McpDocument.d.ts +8 -0
- package/types/signal/signalContext.d.ts +6 -4
- package/types/signal/slice.d.ts +2 -1
- package/types/test/ServedMcpCatalogue.d.ts +15 -0
- package/types/test/index.d.ts +3 -2
- package/types/test/signalTestRuntime.d.ts +8 -1
- package/types/test/testServer.d.ts +15 -0
- 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
|
-
|
|
12
|
+
readSessionToken,
|
|
13
|
+
retiredAuthTokenKeys,
|
|
14
|
+
SessionCookies,
|
|
10
15
|
} from "akanjs/common";
|
|
11
16
|
import type { Account } from "akanjs/fetch";
|
|
12
|
-
import {
|
|
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
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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(
|
|
37
|
+
return JSON.parse(localStorage.getItem(storedCookiesKey) ?? "{}") as Record<string, string>;
|
|
32
38
|
} catch {
|
|
33
39
|
return {};
|
|
34
40
|
}
|
|
35
41
|
};
|
|
36
|
-
const
|
|
37
|
-
localStorage.setItem(
|
|
42
|
+
const writeStoredCookies = (stored: Record<string, string>) => {
|
|
43
|
+
localStorage.setItem(storedCookiesKey, JSON.stringify(stored));
|
|
38
44
|
};
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
64
|
-
export const getCookie = (key: string): string | undefined =>
|
|
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 } =
|
|
71
|
-
|
|
84
|
+
const { [key]: _removed, ...rest } = readStoredCookies();
|
|
85
|
+
writeStoredCookies(rest);
|
|
72
86
|
return;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
|
|
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
|
|
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) ??
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
153
|
+
void secretStorage.setItem(storedAuthTokenKey(), jwt);
|
|
140
154
|
|
|
141
|
-
removeCookie(
|
|
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
|
-
|
|
151
|
-
const
|
|
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 (
|
|
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
|
|
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(
|
|
167
|
-
void secretStorage.removeItem(
|
|
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
|
-
? {
|
|
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) =>
|
|
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
|
+
}
|
package/common/TrustedProxy.ts
CHANGED
|
@@ -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 #
|
|
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.#
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
87
|
+
const bytes = IpLiteral.bytes(normalizeIpAddress(address));
|
|
47
88
|
if (!bytes) return false;
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 =
|
|
123
|
+
const bytes = IpLiteral.bytes(normalizeIpAddress(address));
|
|
64
124
|
if (!bytes) return true;
|
|
65
|
-
if (TrustedProxy.#
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
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):
|
|
89
|
-
const [address, prefix] = entry.split("/");
|
|
90
|
-
|
|
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:
|
|
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
|
+
}
|
package/common/authCookie.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
/** The pre-scoping key.
|
|
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
|
|
7
|
-
export const isAuthTokenKey = (key: string): boolean =>
|
|
8
|
-
|
|
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*)
|
|
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);
|