overmux 0.0.1 → 0.0.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/CHANGELOG.md +16 -0
- package/README.md +7 -0
- package/dist/auth/.vite/manifest.json +11 -0
- package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
- package/dist/auth/assets/index-CHIE7qYj.js +72 -0
- package/dist/auth/index.html +14 -0
- package/dist/bin.js +4504 -7870
- package/dist/bin.js.map +1 -0
- package/dist/docs/000-index.md +21 -0
- package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/dist/docs/100-introduction/300-why-overmux.md +22 -0
- package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
- package/dist/docs/300-fundamentals/200-configuration.md +3 -0
- package/dist/docs/300-fundamentals/300-theming.md +3 -0
- package/dist/docs/300-fundamentals/400-server.md +3 -0
- package/dist/docs/300-fundamentals/500-client.md +3 -0
- package/dist/docs/300-fundamentals/600-operations.md +3 -0
- package/dist/docs/300-fundamentals/700-resources.md +3 -0
- package/dist/docs/300-fundamentals/800-streams.md +3 -0
- package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/dist/docs/400-reference/100-configuration.md +3 -0
- package/dist/docs/400-reference/200-server-api.md +3 -0
- package/dist/docs/400-reference/300-client-api.md +3 -0
- package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
- package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
- package/dist/docs/400-reference/400-cli/300-call.md +28 -0
- package/dist/docs/400-reference/400-cli/400-check.md +20 -0
- package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
- package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/dist/docs/500-hosted-pages.md +31 -0
- package/dist/exports/client.d.ts +237 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +1643 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
- package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +49 -0
- package/dist/exports/index.js.map +1 -0
- package/dist/exports/notifications-kDp16bU_.js +56 -0
- package/dist/exports/notifications-kDp16bU_.js.map +1 -0
- package/dist/exports/server.d.ts +63 -0
- package/dist/exports/server.d.ts.map +1 -0
- package/dist/exports/server.js +1120 -0
- package/dist/exports/server.js.map +1 -0
- package/dist/exports/style.css +368 -0
- package/dist/internal/server/coordinator/server-child.d.ts +1 -0
- package/dist/internal/server/coordinator/server-child.js +3299 -0
- package/dist/internal/server/coordinator/server-child.js.map +1 -0
- package/docs/000-index.md +21 -0
- package/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/docs/100-introduction/300-why-overmux.md +22 -0
- package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/docs/300-fundamentals/100-project-structure.md +3 -0
- package/docs/300-fundamentals/200-configuration.md +3 -0
- package/docs/300-fundamentals/300-theming.md +3 -0
- package/docs/300-fundamentals/400-server.md +3 -0
- package/docs/300-fundamentals/500-client.md +3 -0
- package/docs/300-fundamentals/600-operations.md +3 -0
- package/docs/300-fundamentals/700-resources.md +3 -0
- package/docs/300-fundamentals/800-streams.md +3 -0
- package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/docs/400-reference/100-configuration.md +3 -0
- package/docs/400-reference/200-server-api.md +3 -0
- package/docs/400-reference/300-client-api.md +3 -0
- package/docs/400-reference/400-cli/100-serve.md +33 -0
- package/docs/400-reference/400-cli/200-auth.md +52 -0
- package/docs/400-reference/400-cli/300-call.md +28 -0
- package/docs/400-reference/400-cli/400-check.md +20 -0
- package/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/docs/400-reference/400-cli/600-docs.md +23 -0
- package/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/docs/500-hosted-pages.md +31 -0
- package/package.json +59 -18
- package/src/internal/cli/app.ts +72 -0
- package/src/internal/cli/bin.ts +3 -0
- package/src/internal/cli/commands/ai.ts +44 -0
- package/src/internal/cli/commands/auth.ts +195 -0
- package/src/internal/cli/commands/call.ts +128 -0
- package/src/internal/cli/commands/check.ts +48 -0
- package/src/internal/cli/commands/desktop-installer.ts +446 -0
- package/src/internal/cli/commands/desktop-release.ts +190 -0
- package/src/internal/cli/commands/desktop.ts +47 -0
- package/src/internal/cli/commands/docs.ts +34 -0
- package/src/internal/cli/commands/serve.ts +322 -0
- package/src/internal/cli/environment.ts +54 -0
- package/src/internal/cli/login.ts +46 -0
- package/src/internal/cli/managed-vite.ts +267 -0
- package/src/internal/cli/parse-port.ts +8 -0
- package/src/internal/client/auth/auth-api.ts +53 -0
- package/src/internal/client/auth/auth-shell.css +74 -0
- package/src/internal/client/auth/auth-shell.tsx +167 -0
- package/src/internal/client/auth/entry.tsx +26 -0
- package/src/internal/client/auth/index.html +13 -0
- package/src/internal/client/background-notifications.ts +194 -0
- package/src/internal/client/browser-api.ts +97 -0
- package/src/internal/client/client-definition.ts +184 -0
- package/src/internal/client/commands.tsx +260 -0
- package/src/internal/client/host/browser-log-forwarding.ts +149 -0
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
- package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
- package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
- package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
- package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
- package/src/internal/client/host/notification-forwarding.ts +22 -0
- package/src/internal/client/host/overmux-host.tsx +200 -0
- package/src/internal/client/host/recovery-screen.css +75 -0
- package/src/internal/client/host/recovery-screen.tsx +17 -0
- package/src/internal/client/host/update-popover.css +82 -0
- package/src/internal/client/host/update-popover.tsx +166 -0
- package/src/internal/client/index.ts +45 -0
- package/src/internal/client/overmux-react.ts +283 -0
- package/src/internal/client/shortcuts.tsx +373 -0
- package/src/internal/client/theme-scope.css +114 -0
- package/src/internal/client/theme-scope.tsx +107 -0
- package/src/internal/client/transport.ts +69 -0
- package/src/internal/client/websocket.ts +441 -0
- package/src/internal/server/auth/auth-http.ts +414 -0
- package/src/internal/server/auth/auth-service.ts +627 -0
- package/src/internal/server/auth/auth-shell-http.ts +90 -0
- package/src/internal/server/auth/instance-control.ts +381 -0
- package/src/internal/server/check-overmux.ts +364 -0
- package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
- package/src/internal/server/coordinator/server-child.ts +108 -0
- package/src/internal/server/coordinator/server-coordinator.ts +262 -0
- package/src/internal/server/coordinator/update-watcher.ts +54 -0
- package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
- package/src/internal/server/http/background-notification-handlers.ts +83 -0
- package/src/internal/server/http/control-handlers.ts +44 -0
- package/src/internal/server/http/create-http-app.ts +53 -0
- package/src/internal/server/http/development-web-backend.ts +83 -0
- package/src/internal/server/http/operation-handler.ts +93 -0
- package/src/internal/server/http/request-logging.ts +34 -0
- package/src/internal/server/http/routes.ts +241 -0
- package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
- package/src/internal/server/index.ts +16 -0
- package/src/internal/server/notifications/background-notification-payload.ts +74 -0
- package/src/internal/server/notifications/background-notification-service.ts +139 -0
- package/src/internal/server/notifications/background-notification-store.ts +162 -0
- package/src/internal/server/notifications/notification-service.ts +70 -0
- package/src/internal/server/notifications/private-json-file.ts +84 -0
- package/src/internal/server/paths.ts +13 -0
- package/src/internal/server/rate-limiter.ts +58 -0
- package/src/internal/server/runtime/create-runtime.ts +87 -0
- package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
- package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
- package/src/internal/server/runtime/runtime-operations.ts +78 -0
- package/src/internal/server/runtime/runtime-resources.ts +201 -0
- package/src/internal/server/runtime/runtime-streams.ts +130 -0
- package/src/internal/server/server-logger.ts +110 -0
- package/src/internal/server/server-startup-options.ts +51 -0
- package/src/internal/server/start-application-server.ts +412 -0
- package/src/internal/server/start-overmux-server.ts +26 -0
- package/src/internal/server/test-port.ts +23 -0
- package/src/internal/server/vite-websocket-proxy.ts +201 -0
- package/src/internal/server/websocket-connection.ts +586 -0
- package/src/internal/server/websocket-limits.ts +2 -0
- package/src/internal/server/websocket-server.ts +311 -0
- package/src/internal/shared/background-notifications.ts +37 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +317 -0
- package/src/internal/shared/routes.ts +3 -0
- package/src/public/ai-context.ts +44 -0
- package/src/public/client.ts +53 -0
- package/src/public/config.ts +287 -0
- package/src/public/contracts.ts +55 -0
- package/src/public/index.ts +57 -0
- package/src/public/notifications.ts +10 -0
- package/src/public/server.ts +16 -0
- package/dist/public/core.ts +0 -119
- package/dist/public/protocol.ts +0 -51
- package/dist/public/terminal.ts +0 -23
- package/dist/web/assets/index-DC___IUw.css +0 -2
- package/dist/web/assets/index-DF9Cptse.js +0 -74
- package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
- package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
- package/dist/web/index.html +0 -14
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
randomBytes,
|
|
4
|
+
randomUUID,
|
|
5
|
+
timingSafeEqual,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import { chmod, mkdir, open, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { durationToMilliseconds } from "@overmux/lib";
|
|
11
|
+
import { authDurationSchema } from "@overmux/shared/node";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import type { AuthConfigDefinition } from "../../../public/config";
|
|
15
|
+
import {
|
|
16
|
+
createPrivateJsonExclusively,
|
|
17
|
+
readPrivateJson,
|
|
18
|
+
replacePrivateJsonAtomically,
|
|
19
|
+
} from "../notifications/private-json-file";
|
|
20
|
+
import { getOvermuxPaths } from "../paths";
|
|
21
|
+
|
|
22
|
+
// Login grants are brief, single-use browser bootstrap credentials. Local
|
|
23
|
+
// bearers last only long enough for a CLI request because the owner-only control
|
|
24
|
+
// socket can cheaply issue another without interactive authentication.
|
|
25
|
+
const loginGrantLifetimeMs = 10 * 60_000;
|
|
26
|
+
const localBearerLifetimeMs = 60_000;
|
|
27
|
+
const maxCodeAttempts = 8;
|
|
28
|
+
const lastSeenWriteIntervalMs = 60_000;
|
|
29
|
+
const codeAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
30
|
+
|
|
31
|
+
const loginGrantSchema = z.object({
|
|
32
|
+
codeHash: z.string(),
|
|
33
|
+
codeLookupHash: z.string(),
|
|
34
|
+
consumedAt: z.string().optional(),
|
|
35
|
+
createdAt: z.string(),
|
|
36
|
+
expiresAt: z.string(),
|
|
37
|
+
failedCodeAttempts: z.number().int().nonnegative(),
|
|
38
|
+
id: z.string(),
|
|
39
|
+
linkTokenHash: z.string(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const authSessionSchema = z.object({
|
|
43
|
+
createdAt: z.string(),
|
|
44
|
+
expiresAt: z.string().optional(),
|
|
45
|
+
id: z.string(),
|
|
46
|
+
kind: z.enum(["browser", "local"]),
|
|
47
|
+
lastSeenAt: z.string(),
|
|
48
|
+
origin: z.string().optional(),
|
|
49
|
+
revokedAt: z.string().optional(),
|
|
50
|
+
tokenHash: z.string(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const authStoreSchema = z.object({
|
|
54
|
+
grants: z.array(loginGrantSchema),
|
|
55
|
+
installationId: z.string(),
|
|
56
|
+
sessions: z.array(authSessionSchema),
|
|
57
|
+
version: z.literal(1),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type AuthStore = z.infer<typeof authStoreSchema>;
|
|
61
|
+
type StoredSession = z.infer<typeof authSessionSchema>;
|
|
62
|
+
|
|
63
|
+
export type AuthSession = Omit<StoredSession, "kind" | "tokenHash">;
|
|
64
|
+
export type LoginGrant = {
|
|
65
|
+
code: string;
|
|
66
|
+
expiresAt: string;
|
|
67
|
+
id: string;
|
|
68
|
+
urls: string[];
|
|
69
|
+
};
|
|
70
|
+
export type LoginResult =
|
|
71
|
+
| { status: "expired" | "invalid" | "rate-limited" }
|
|
72
|
+
| { session: AuthSession; status: "authenticated"; token: string };
|
|
73
|
+
export type AuthenticatedSession = {
|
|
74
|
+
expiresAt?: string;
|
|
75
|
+
id: string;
|
|
76
|
+
kind: "browser" | "local";
|
|
77
|
+
origin?: string;
|
|
78
|
+
token: string;
|
|
79
|
+
};
|
|
80
|
+
// AuthService is transport-neutral and persists grants and sessions across
|
|
81
|
+
// server replacement. Opaque credentials are returned once; only their hashes
|
|
82
|
+
// and non-secret lifecycle metadata are stored. Every session represents
|
|
83
|
+
// authority delegated by the OS user; there are no application users or roles.
|
|
84
|
+
export type AuthService = {
|
|
85
|
+
authenticateToken: (
|
|
86
|
+
token: string,
|
|
87
|
+
origin?: string,
|
|
88
|
+
) => Promise<AuthenticatedSession | undefined>;
|
|
89
|
+
consumeLoginGrant: (input: {
|
|
90
|
+
code?: string;
|
|
91
|
+
origin: string;
|
|
92
|
+
ticket?: string;
|
|
93
|
+
}) => Promise<LoginResult>;
|
|
94
|
+
createLoginGrant: () => Promise<LoginGrant>;
|
|
95
|
+
issueLocalBearer: () => Promise<{ expiresAt: string; token: string }>;
|
|
96
|
+
isSessionActive: (id: string) => Promise<boolean>;
|
|
97
|
+
listSessions: () => Promise<AuthSession[]>;
|
|
98
|
+
onSessionsRevoked: (
|
|
99
|
+
listener: (ids: ReadonlySet<string>) => void,
|
|
100
|
+
) => () => void;
|
|
101
|
+
revokeAllSessions: () => Promise<number>;
|
|
102
|
+
revokeSession: (id: string) => Promise<boolean>;
|
|
103
|
+
setOrigins: (origins: string[]) => void;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const tokenHash = (token: string) =>
|
|
107
|
+
createHash("sha256").update(token, "utf8").digest("hex");
|
|
108
|
+
|
|
109
|
+
const hashesEqual = (left: string, right: string) => {
|
|
110
|
+
const leftBytes = Buffer.from(left, "hex");
|
|
111
|
+
const rightBytes = Buffer.from(right, "hex");
|
|
112
|
+
return (
|
|
113
|
+
leftBytes.length === rightBytes.length &&
|
|
114
|
+
timingSafeEqual(leftBytes, rightBytes)
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const randomToken = () => randomBytes(32).toString("base64url");
|
|
119
|
+
|
|
120
|
+
const randomCode = () => {
|
|
121
|
+
const bytes = randomBytes(8);
|
|
122
|
+
const characters = Array.from(
|
|
123
|
+
bytes,
|
|
124
|
+
(byte) => codeAlphabet[byte % codeAlphabet.length],
|
|
125
|
+
);
|
|
126
|
+
return `${characters.slice(0, 4).join("")}-${characters.slice(4).join("")}`;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const durationMilliseconds = (
|
|
130
|
+
duration: Exclude<
|
|
131
|
+
AuthConfigDefinition["sessionLifetime"],
|
|
132
|
+
"never" | undefined
|
|
133
|
+
>,
|
|
134
|
+
) => {
|
|
135
|
+
const milliseconds = durationToMilliseconds(
|
|
136
|
+
authDurationSchema.parse(duration),
|
|
137
|
+
);
|
|
138
|
+
if (milliseconds === undefined) {
|
|
139
|
+
throw new Error("Expected a validated authentication duration");
|
|
140
|
+
}
|
|
141
|
+
return milliseconds;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Old and replacement server processes may overlap, so every read-modify-write
|
|
145
|
+
// transaction takes a filesystem lock. This is what makes grant consumption and
|
|
146
|
+
// revocation atomic across processes, not only within one AuthService instance.
|
|
147
|
+
const acquireStoreLock = async (
|
|
148
|
+
path: string,
|
|
149
|
+
deadline = Date.now() + 5_000,
|
|
150
|
+
): Promise<() => Promise<void>> => {
|
|
151
|
+
await mkdir(dirname(path), { mode: 0o700, recursive: true });
|
|
152
|
+
await chmod(dirname(path), 0o700);
|
|
153
|
+
try {
|
|
154
|
+
const handle = await open(path, "wx", 0o600);
|
|
155
|
+
await handle.close();
|
|
156
|
+
return () => rm(path, { force: true });
|
|
157
|
+
} catch (cause) {
|
|
158
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
159
|
+
throw cause;
|
|
160
|
+
}
|
|
161
|
+
let lockAge: number;
|
|
162
|
+
try {
|
|
163
|
+
lockAge = Date.now() - (await stat(path)).mtimeMs;
|
|
164
|
+
} catch (statCause) {
|
|
165
|
+
if ((statCause as NodeJS.ErrnoException).code === "ENOENT") {
|
|
166
|
+
return acquireStoreLock(path, deadline);
|
|
167
|
+
}
|
|
168
|
+
throw statCause;
|
|
169
|
+
}
|
|
170
|
+
if (lockAge > 30_000) {
|
|
171
|
+
await rm(path, { force: true });
|
|
172
|
+
return acquireStoreLock(path, deadline);
|
|
173
|
+
}
|
|
174
|
+
if (Date.now() >= deadline) {
|
|
175
|
+
throw new Error("Timed out waiting for the authentication store lock");
|
|
176
|
+
}
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
178
|
+
return acquireStoreLock(path, deadline);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const dataDirectory = ({
|
|
183
|
+
environment,
|
|
184
|
+
homeDirectory,
|
|
185
|
+
}: {
|
|
186
|
+
environment: NodeJS.ProcessEnv;
|
|
187
|
+
homeDirectory: string;
|
|
188
|
+
}) =>
|
|
189
|
+
join(
|
|
190
|
+
getOvermuxPaths({ environment, homeDir: homeDirectory }).dataDir,
|
|
191
|
+
"auth",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const publicSession = ({
|
|
195
|
+
kind: _kind,
|
|
196
|
+
tokenHash: _tokenHash,
|
|
197
|
+
...session
|
|
198
|
+
}: StoredSession): AuthSession => session;
|
|
199
|
+
const activeSession = (session: StoredSession, now: number) =>
|
|
200
|
+
!session.revokedAt &&
|
|
201
|
+
(session.kind === "local" || Boolean(session.origin)) &&
|
|
202
|
+
(!session.expiresAt || Date.parse(session.expiresAt) > now);
|
|
203
|
+
|
|
204
|
+
const revokeExpired = (store: AuthStore, currentTime: number) => {
|
|
205
|
+
const expiredIds: string[] = [];
|
|
206
|
+
const sessions = store.sessions.map((session) => {
|
|
207
|
+
if (
|
|
208
|
+
!session.revokedAt &&
|
|
209
|
+
session.expiresAt &&
|
|
210
|
+
Date.parse(session.expiresAt) <= currentTime
|
|
211
|
+
) {
|
|
212
|
+
expiredIds.push(session.id);
|
|
213
|
+
return { ...session, revokedAt: new Date(currentTime).toISOString() };
|
|
214
|
+
}
|
|
215
|
+
return session;
|
|
216
|
+
});
|
|
217
|
+
return { expiredIds, sessions };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const authenticateStoredToken = ({
|
|
221
|
+
currentTime,
|
|
222
|
+
store,
|
|
223
|
+
origin,
|
|
224
|
+
token,
|
|
225
|
+
}: {
|
|
226
|
+
currentTime: number;
|
|
227
|
+
origin?: string;
|
|
228
|
+
store: AuthStore;
|
|
229
|
+
token: string;
|
|
230
|
+
}) => {
|
|
231
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
232
|
+
const expectedHash = tokenHash(token);
|
|
233
|
+
const session = sessions.find((candidate) =>
|
|
234
|
+
hashesEqual(candidate.tokenHash, expectedHash),
|
|
235
|
+
);
|
|
236
|
+
const authenticated =
|
|
237
|
+
session &&
|
|
238
|
+
activeSession(session, currentTime) &&
|
|
239
|
+
(session.kind === "local" || session.origin === origin)
|
|
240
|
+
? {
|
|
241
|
+
...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
|
|
242
|
+
id: session.id,
|
|
243
|
+
kind: session.kind,
|
|
244
|
+
...(session.origin ? { origin: session.origin } : {}),
|
|
245
|
+
token,
|
|
246
|
+
}
|
|
247
|
+
: undefined;
|
|
248
|
+
const updateLastSeen = Boolean(
|
|
249
|
+
session &&
|
|
250
|
+
authenticated &&
|
|
251
|
+
currentTime - Date.parse(session.lastSeenAt) >= lastSeenWriteIntervalMs,
|
|
252
|
+
);
|
|
253
|
+
const nextSessions = updateLastSeen
|
|
254
|
+
? sessions.map((candidate) =>
|
|
255
|
+
candidate.id === session?.id
|
|
256
|
+
? { ...candidate, lastSeenAt: new Date(currentTime).toISOString() }
|
|
257
|
+
: candidate,
|
|
258
|
+
)
|
|
259
|
+
: sessions;
|
|
260
|
+
return { authenticated, expiredIds, nextSessions, updateLastSeen };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const createAuthService = ({
|
|
264
|
+
config,
|
|
265
|
+
environment = process.env,
|
|
266
|
+
homeDirectory = homedir(),
|
|
267
|
+
now = Date.now,
|
|
268
|
+
}: {
|
|
269
|
+
config: AuthConfigDefinition;
|
|
270
|
+
environment?: NodeJS.ProcessEnv;
|
|
271
|
+
homeDirectory?: string;
|
|
272
|
+
now?: () => number;
|
|
273
|
+
}): AuthService => {
|
|
274
|
+
const directory = dataDirectory({ environment, homeDirectory });
|
|
275
|
+
const storePath = join(directory, "auth.json");
|
|
276
|
+
const lockPath = join(directory, "auth.lock");
|
|
277
|
+
const listeners = new Set<(ids: ReadonlySet<string>) => void>();
|
|
278
|
+
let origins = config.origins;
|
|
279
|
+
let pendingWrite = Promise.resolve();
|
|
280
|
+
|
|
281
|
+
const readStore = async (): Promise<AuthStore> => {
|
|
282
|
+
const existing = await readPrivateJson(storePath, authStoreSchema);
|
|
283
|
+
if (existing) {
|
|
284
|
+
return existing;
|
|
285
|
+
}
|
|
286
|
+
const initial: AuthStore = {
|
|
287
|
+
grants: [],
|
|
288
|
+
installationId: randomUUID(),
|
|
289
|
+
sessions: [],
|
|
290
|
+
version: 1,
|
|
291
|
+
};
|
|
292
|
+
try {
|
|
293
|
+
await createPrivateJsonExclusively(storePath, initial);
|
|
294
|
+
return initial;
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
297
|
+
throw cause;
|
|
298
|
+
}
|
|
299
|
+
const concurrentlyCreated = await readPrivateJson(
|
|
300
|
+
storePath,
|
|
301
|
+
authStoreSchema,
|
|
302
|
+
);
|
|
303
|
+
if (!concurrentlyCreated) {
|
|
304
|
+
throw cause;
|
|
305
|
+
}
|
|
306
|
+
return concurrentlyCreated;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const updateStore = <T>(
|
|
311
|
+
update: (store: AuthStore) => {
|
|
312
|
+
result: T;
|
|
313
|
+
revokedIds?: string[];
|
|
314
|
+
store?: AuthStore;
|
|
315
|
+
},
|
|
316
|
+
) => {
|
|
317
|
+
const operation = pendingWrite.then(async () => {
|
|
318
|
+
const release = await acquireStoreLock(lockPath);
|
|
319
|
+
try {
|
|
320
|
+
const current = await readStore();
|
|
321
|
+
const updated = update(current);
|
|
322
|
+
if (updated.store) {
|
|
323
|
+
await replacePrivateJsonAtomically(storePath, updated.store);
|
|
324
|
+
}
|
|
325
|
+
emitRevoked(updated.revokedIds ?? []);
|
|
326
|
+
return updated.result;
|
|
327
|
+
} finally {
|
|
328
|
+
await release();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
pendingWrite = operation.then(
|
|
332
|
+
() => undefined,
|
|
333
|
+
() => undefined,
|
|
334
|
+
);
|
|
335
|
+
return operation;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Revocation is the shared lifecycle signal for closing WebSockets and
|
|
339
|
+
// removing session-owned background notification subscriptions.
|
|
340
|
+
const emitRevoked = (ids: string[]) => {
|
|
341
|
+
if (ids.length === 0) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const revoked = new Set(ids);
|
|
345
|
+
listeners.forEach((listener) => listener(revoked));
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const createSession = ({
|
|
349
|
+
kind,
|
|
350
|
+
lifetimeMs,
|
|
351
|
+
origin,
|
|
352
|
+
}: {
|
|
353
|
+
kind: StoredSession["kind"];
|
|
354
|
+
lifetimeMs?: number;
|
|
355
|
+
origin?: string;
|
|
356
|
+
}) => {
|
|
357
|
+
const token = randomToken();
|
|
358
|
+
const createdAt = new Date(now()).toISOString();
|
|
359
|
+
const configuredLifetime =
|
|
360
|
+
config.sessionLifetime && config.sessionLifetime !== "never"
|
|
361
|
+
? durationMilliseconds(config.sessionLifetime)
|
|
362
|
+
: undefined;
|
|
363
|
+
const expiresIn = lifetimeMs ?? configuredLifetime;
|
|
364
|
+
const stored: StoredSession = {
|
|
365
|
+
createdAt,
|
|
366
|
+
...(expiresIn
|
|
367
|
+
? { expiresAt: new Date(now() + expiresIn).toISOString() }
|
|
368
|
+
: {}),
|
|
369
|
+
id: randomUUID(),
|
|
370
|
+
kind,
|
|
371
|
+
lastSeenAt: createdAt,
|
|
372
|
+
...(origin ? { origin } : {}),
|
|
373
|
+
tokenHash: tokenHash(token),
|
|
374
|
+
};
|
|
375
|
+
return { stored, token };
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const authenticateToken = async (token: string, origin?: string) => {
|
|
379
|
+
await pendingWrite;
|
|
380
|
+
const currentTime = now();
|
|
381
|
+
const inspected = authenticateStoredToken({
|
|
382
|
+
currentTime,
|
|
383
|
+
origin,
|
|
384
|
+
store: await readStore(),
|
|
385
|
+
token,
|
|
386
|
+
});
|
|
387
|
+
if (inspected.expiredIds.length === 0 && !inspected.updateLastSeen) {
|
|
388
|
+
return inspected.authenticated;
|
|
389
|
+
}
|
|
390
|
+
return updateStore((store) => {
|
|
391
|
+
const current = authenticateStoredToken({
|
|
392
|
+
currentTime: now(),
|
|
393
|
+
origin,
|
|
394
|
+
store,
|
|
395
|
+
token,
|
|
396
|
+
});
|
|
397
|
+
return {
|
|
398
|
+
result: current.authenticated,
|
|
399
|
+
revokedIds: current.expiredIds,
|
|
400
|
+
store: { ...store, sessions: current.nextSessions },
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
authenticateToken,
|
|
407
|
+
consumeLoginGrant: (input) =>
|
|
408
|
+
updateStore<LoginResult>((store) => {
|
|
409
|
+
const currentTime = now();
|
|
410
|
+
const presented = input.code?.trim().toUpperCase() ?? input.ticket;
|
|
411
|
+
if (!presented || Boolean(input.code) === Boolean(input.ticket)) {
|
|
412
|
+
return { result: { status: "invalid" } as const };
|
|
413
|
+
}
|
|
414
|
+
const expectedHash = tokenHash(presented);
|
|
415
|
+
const codeLookupHash = input.code
|
|
416
|
+
? tokenHash(presented.split("-", 1)[0] ?? "")
|
|
417
|
+
: undefined;
|
|
418
|
+
const exactCodeIndex = store.grants.findIndex((grant) =>
|
|
419
|
+
hashesEqual(grant.codeHash, expectedHash),
|
|
420
|
+
);
|
|
421
|
+
const codeIndex =
|
|
422
|
+
exactCodeIndex >= 0
|
|
423
|
+
? exactCodeIndex
|
|
424
|
+
: store.grants.findIndex((grant) =>
|
|
425
|
+
hashesEqual(grant.codeLookupHash, codeLookupHash ?? ""),
|
|
426
|
+
);
|
|
427
|
+
const index = input.code
|
|
428
|
+
? codeIndex
|
|
429
|
+
: store.grants.findIndex((grant) =>
|
|
430
|
+
hashesEqual(grant.linkTokenHash, expectedHash),
|
|
431
|
+
);
|
|
432
|
+
if (index < 0) {
|
|
433
|
+
return { result: { status: "invalid" } as const };
|
|
434
|
+
}
|
|
435
|
+
const grant = store.grants[index]!;
|
|
436
|
+
if (grant.consumedAt) {
|
|
437
|
+
return { result: { status: "invalid" } as const };
|
|
438
|
+
}
|
|
439
|
+
if (Date.parse(grant.expiresAt) <= currentTime) {
|
|
440
|
+
return { result: { status: "expired" } as const };
|
|
441
|
+
}
|
|
442
|
+
if (input.code && grant.failedCodeAttempts >= maxCodeAttempts) {
|
|
443
|
+
return { result: { status: "rate-limited" } as const };
|
|
444
|
+
}
|
|
445
|
+
if (input.code && !hashesEqual(grant.codeHash, expectedHash)) {
|
|
446
|
+
const grants = store.grants.map((candidate, grantIndex) =>
|
|
447
|
+
grantIndex === index
|
|
448
|
+
? {
|
|
449
|
+
...candidate,
|
|
450
|
+
failedCodeAttempts: candidate.failedCodeAttempts + 1,
|
|
451
|
+
}
|
|
452
|
+
: candidate,
|
|
453
|
+
);
|
|
454
|
+
return {
|
|
455
|
+
result: { status: "invalid" } as const,
|
|
456
|
+
store: { ...store, grants },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const { stored, token } = createSession({
|
|
460
|
+
kind: "browser",
|
|
461
|
+
origin: input.origin,
|
|
462
|
+
});
|
|
463
|
+
const grants = store.grants.map((candidate, grantIndex) =>
|
|
464
|
+
grantIndex === index
|
|
465
|
+
? { ...candidate, consumedAt: new Date(currentTime).toISOString() }
|
|
466
|
+
: candidate,
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
result: {
|
|
470
|
+
session: publicSession(stored),
|
|
471
|
+
status: "authenticated",
|
|
472
|
+
token,
|
|
473
|
+
} as const,
|
|
474
|
+
store: {
|
|
475
|
+
...store,
|
|
476
|
+
grants,
|
|
477
|
+
sessions: [
|
|
478
|
+
...store.sessions.filter((session) =>
|
|
479
|
+
activeSession(session, currentTime),
|
|
480
|
+
),
|
|
481
|
+
stored,
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}),
|
|
486
|
+
createLoginGrant: () =>
|
|
487
|
+
updateStore((store) => {
|
|
488
|
+
if (!origins) {
|
|
489
|
+
throw new Error("The server has not established its browser origins");
|
|
490
|
+
}
|
|
491
|
+
const code = randomCode();
|
|
492
|
+
const ticket = randomToken();
|
|
493
|
+
const createdAt = new Date(now()).toISOString();
|
|
494
|
+
const expiresAt = new Date(now() + loginGrantLifetimeMs).toISOString();
|
|
495
|
+
const id = randomUUID();
|
|
496
|
+
const grant = {
|
|
497
|
+
codeHash: tokenHash(code),
|
|
498
|
+
codeLookupHash: tokenHash(code.split("-", 1)[0]!),
|
|
499
|
+
createdAt,
|
|
500
|
+
expiresAt,
|
|
501
|
+
failedCodeAttempts: 0,
|
|
502
|
+
id,
|
|
503
|
+
linkTokenHash: tokenHash(ticket),
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
result: {
|
|
507
|
+
code,
|
|
508
|
+
expiresAt,
|
|
509
|
+
id,
|
|
510
|
+
urls: origins.map(
|
|
511
|
+
(origin) =>
|
|
512
|
+
`${origin}/login#ticket=${encodeURIComponent(ticket)}`,
|
|
513
|
+
),
|
|
514
|
+
},
|
|
515
|
+
store: {
|
|
516
|
+
...store,
|
|
517
|
+
grants: [
|
|
518
|
+
...store.grants.filter(
|
|
519
|
+
(candidate) =>
|
|
520
|
+
Date.parse(candidate.expiresAt) > now() &&
|
|
521
|
+
!candidate.consumedAt,
|
|
522
|
+
),
|
|
523
|
+
grant,
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}),
|
|
528
|
+
issueLocalBearer: () =>
|
|
529
|
+
updateStore((store) => {
|
|
530
|
+
const currentTime = now();
|
|
531
|
+
const { stored, token } = createSession({
|
|
532
|
+
kind: "local",
|
|
533
|
+
lifetimeMs: localBearerLifetimeMs,
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
result: { expiresAt: stored.expiresAt!, token },
|
|
537
|
+
store: {
|
|
538
|
+
...store,
|
|
539
|
+
sessions: [
|
|
540
|
+
...store.sessions.filter((session) =>
|
|
541
|
+
activeSession(session, currentTime),
|
|
542
|
+
),
|
|
543
|
+
stored,
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}),
|
|
548
|
+
isSessionActive: (id) =>
|
|
549
|
+
updateStore((store) => {
|
|
550
|
+
const currentTime = now();
|
|
551
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
552
|
+
return {
|
|
553
|
+
result: Boolean(
|
|
554
|
+
sessions.find(
|
|
555
|
+
(session) =>
|
|
556
|
+
session.id === id && activeSession(session, currentTime),
|
|
557
|
+
),
|
|
558
|
+
),
|
|
559
|
+
revokedIds: expiredIds,
|
|
560
|
+
...(expiredIds.length > 0 ? { store: { ...store, sessions } } : {}),
|
|
561
|
+
};
|
|
562
|
+
}),
|
|
563
|
+
listSessions: () =>
|
|
564
|
+
updateStore((store) => {
|
|
565
|
+
const currentTime = now();
|
|
566
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
567
|
+
return {
|
|
568
|
+
result: sessions
|
|
569
|
+
.filter(
|
|
570
|
+
(session) =>
|
|
571
|
+
session.kind === "browser" &&
|
|
572
|
+
activeSession(session, currentTime),
|
|
573
|
+
)
|
|
574
|
+
.map(publicSession),
|
|
575
|
+
revokedIds: expiredIds,
|
|
576
|
+
...(expiredIds.length > 0 ? { store: { ...store, sessions } } : {}),
|
|
577
|
+
};
|
|
578
|
+
}),
|
|
579
|
+
onSessionsRevoked: (listener) => {
|
|
580
|
+
listeners.add(listener);
|
|
581
|
+
return () => listeners.delete(listener);
|
|
582
|
+
},
|
|
583
|
+
revokeAllSessions: () =>
|
|
584
|
+
updateStore((store) => {
|
|
585
|
+
const currentTime = now();
|
|
586
|
+
const expired = revokeExpired(store, currentTime);
|
|
587
|
+
const ids = expired.sessions
|
|
588
|
+
.filter(
|
|
589
|
+
(session) =>
|
|
590
|
+
session.kind === "browser" && activeSession(session, currentTime),
|
|
591
|
+
)
|
|
592
|
+
.map((session) => session.id);
|
|
593
|
+
const revoked = new Set(ids);
|
|
594
|
+
const revokedAt = new Date(currentTime).toISOString();
|
|
595
|
+
const sessions = expired.sessions.map((session) =>
|
|
596
|
+
revoked.has(session.id) ? { ...session, revokedAt } : session,
|
|
597
|
+
);
|
|
598
|
+
const revokedIds = [...expired.expiredIds, ...ids];
|
|
599
|
+
return {
|
|
600
|
+
result: ids.length,
|
|
601
|
+
revokedIds,
|
|
602
|
+
...(revokedIds.length > 0 ? { store: { ...store, sessions } } : {}),
|
|
603
|
+
};
|
|
604
|
+
}),
|
|
605
|
+
revokeSession: (id) =>
|
|
606
|
+
updateStore((store) => {
|
|
607
|
+
const session = store.sessions.find(
|
|
608
|
+
(candidate) => candidate.id === id && !candidate.revokedAt,
|
|
609
|
+
);
|
|
610
|
+
if (!session) {
|
|
611
|
+
return { result: false };
|
|
612
|
+
}
|
|
613
|
+
const revokedAt = new Date(now()).toISOString();
|
|
614
|
+
const sessions = store.sessions.map((candidate) =>
|
|
615
|
+
candidate.id === id ? { ...candidate, revokedAt } : candidate,
|
|
616
|
+
);
|
|
617
|
+
return {
|
|
618
|
+
result: true,
|
|
619
|
+
revokedIds: [id],
|
|
620
|
+
store: { ...store, sessions },
|
|
621
|
+
};
|
|
622
|
+
}),
|
|
623
|
+
setOrigins: (nextOrigins) => {
|
|
624
|
+
origins = nextOrigins.map((origin) => new URL(origin).origin);
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
|
|
5
|
+
const authAssetPrefix = "/_overmux/auth-shell/";
|
|
6
|
+
const authEntry = "index.html";
|
|
7
|
+
const fingerprintedAssetPattern =
|
|
8
|
+
/^assets\/[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}\.(?:css|js)$/;
|
|
9
|
+
|
|
10
|
+
type AuthManifest = Record<string, { css?: string[]; file?: string }>;
|
|
11
|
+
type AuthAsset = { body: ArrayBuffer; contentType: string };
|
|
12
|
+
|
|
13
|
+
export type AuthShell = {
|
|
14
|
+
files: Map<string, AuthAsset>;
|
|
15
|
+
html: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const defaultAuthAssetsDirectory = () =>
|
|
19
|
+
fileURLToPath(new URL("../auth/", import.meta.resolve("overmux")));
|
|
20
|
+
|
|
21
|
+
export const loadAuthShell = (
|
|
22
|
+
directory = defaultAuthAssetsDirectory(),
|
|
23
|
+
): AuthShell => {
|
|
24
|
+
const manifest = JSON.parse(
|
|
25
|
+
readFileSync(`${directory}/.vite/manifest.json`, "utf8"),
|
|
26
|
+
) as AuthManifest;
|
|
27
|
+
const entry = manifest[authEntry];
|
|
28
|
+
if (!entry?.file || !entry.css?.length) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"The packaged Overmux authentication assets are incomplete",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const paths = new Set(
|
|
34
|
+
Object.values(manifest).flatMap(({ css = [], file }) =>
|
|
35
|
+
file ? [file, ...css] : css,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
const invalidPath = [...paths].find(
|
|
39
|
+
(path) => !fingerprintedAssetPattern.test(path),
|
|
40
|
+
);
|
|
41
|
+
if (invalidPath) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Invalid Overmux authentication asset path: ${invalidPath}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const files = new Map(
|
|
47
|
+
[...paths].map((path) => [
|
|
48
|
+
path,
|
|
49
|
+
{
|
|
50
|
+
body: Uint8Array.from(readFileSync(`${directory}/${path}`)).buffer,
|
|
51
|
+
contentType: path.endsWith(".css")
|
|
52
|
+
? "text/css; charset=UTF-8"
|
|
53
|
+
: "text/javascript; charset=UTF-8",
|
|
54
|
+
},
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
return { files, html: readFileSync(`${directory}/index.html`, "utf8") };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const serveAuthShellAsset = (context: Context, shell: AuthShell) => {
|
|
61
|
+
const asset = shell.files.get(context.req.path.slice(authAssetPrefix.length));
|
|
62
|
+
if (!asset) {
|
|
63
|
+
return context.body(null, 404);
|
|
64
|
+
}
|
|
65
|
+
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
66
|
+
context.header("Content-Type", asset.contentType);
|
|
67
|
+
context.header("Cross-Origin-Resource-Policy", "same-origin");
|
|
68
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
69
|
+
return context.body(asset.body);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const renderAuthShell = (
|
|
73
|
+
context: Context,
|
|
74
|
+
shell: AuthShell,
|
|
75
|
+
status: 200 | 401 = 200,
|
|
76
|
+
) => {
|
|
77
|
+
context.header("Cache-Control", "no-store");
|
|
78
|
+
context.header(
|
|
79
|
+
"Content-Security-Policy",
|
|
80
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
81
|
+
);
|
|
82
|
+
context.header("Cross-Origin-Resource-Policy", "same-origin");
|
|
83
|
+
context.header("Referrer-Policy", "no-referrer");
|
|
84
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
85
|
+
return context.html(shell.html, status);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const isBrowserNavigation = (request: Request) =>
|
|
89
|
+
request.headers.get("Sec-Fetch-Dest") === "document" ||
|
|
90
|
+
request.headers.get("Accept")?.includes("text/html");
|