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.
Files changed (193) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +7 -0
  3. package/dist/auth/.vite/manifest.json +11 -0
  4. package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
  5. package/dist/auth/assets/index-CHIE7qYj.js +72 -0
  6. package/dist/auth/index.html +14 -0
  7. package/dist/bin.js +4504 -7870
  8. package/dist/bin.js.map +1 -0
  9. package/dist/docs/000-index.md +21 -0
  10. package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
  11. package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
  12. package/dist/docs/100-introduction/300-why-overmux.md +22 -0
  13. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  14. package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  15. package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  16. package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  17. package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  18. package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  19. package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  20. package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
  21. package/dist/docs/300-fundamentals/200-configuration.md +3 -0
  22. package/dist/docs/300-fundamentals/300-theming.md +3 -0
  23. package/dist/docs/300-fundamentals/400-server.md +3 -0
  24. package/dist/docs/300-fundamentals/500-client.md +3 -0
  25. package/dist/docs/300-fundamentals/600-operations.md +3 -0
  26. package/dist/docs/300-fundamentals/700-resources.md +3 -0
  27. package/dist/docs/300-fundamentals/800-streams.md +3 -0
  28. package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  29. package/dist/docs/400-reference/100-configuration.md +3 -0
  30. package/dist/docs/400-reference/200-server-api.md +3 -0
  31. package/dist/docs/400-reference/300-client-api.md +3 -0
  32. package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
  33. package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
  34. package/dist/docs/400-reference/400-cli/300-call.md +28 -0
  35. package/dist/docs/400-reference/400-cli/400-check.md +20 -0
  36. package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
  37. package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
  38. package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
  39. package/dist/docs/500-hosted-pages.md +31 -0
  40. package/dist/exports/client.d.ts +237 -0
  41. package/dist/exports/client.d.ts.map +1 -0
  42. package/dist/exports/client.js +1643 -0
  43. package/dist/exports/client.js.map +1 -0
  44. package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
  45. package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
  46. package/dist/exports/index.d.ts +2 -0
  47. package/dist/exports/index.js +49 -0
  48. package/dist/exports/index.js.map +1 -0
  49. package/dist/exports/notifications-kDp16bU_.js +56 -0
  50. package/dist/exports/notifications-kDp16bU_.js.map +1 -0
  51. package/dist/exports/server.d.ts +63 -0
  52. package/dist/exports/server.d.ts.map +1 -0
  53. package/dist/exports/server.js +1120 -0
  54. package/dist/exports/server.js.map +1 -0
  55. package/dist/exports/style.css +368 -0
  56. package/dist/internal/server/coordinator/server-child.d.ts +1 -0
  57. package/dist/internal/server/coordinator/server-child.js +3299 -0
  58. package/dist/internal/server/coordinator/server-child.js.map +1 -0
  59. package/docs/000-index.md +21 -0
  60. package/docs/100-introduction/100-what-is-overmux.md +7 -0
  61. package/docs/100-introduction/200-how-overmux-works.md +7 -0
  62. package/docs/100-introduction/300-why-overmux.md +22 -0
  63. package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  64. package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  65. package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  66. package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  67. package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  68. package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  69. package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  70. package/docs/300-fundamentals/100-project-structure.md +3 -0
  71. package/docs/300-fundamentals/200-configuration.md +3 -0
  72. package/docs/300-fundamentals/300-theming.md +3 -0
  73. package/docs/300-fundamentals/400-server.md +3 -0
  74. package/docs/300-fundamentals/500-client.md +3 -0
  75. package/docs/300-fundamentals/600-operations.md +3 -0
  76. package/docs/300-fundamentals/700-resources.md +3 -0
  77. package/docs/300-fundamentals/800-streams.md +3 -0
  78. package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  79. package/docs/400-reference/100-configuration.md +3 -0
  80. package/docs/400-reference/200-server-api.md +3 -0
  81. package/docs/400-reference/300-client-api.md +3 -0
  82. package/docs/400-reference/400-cli/100-serve.md +33 -0
  83. package/docs/400-reference/400-cli/200-auth.md +52 -0
  84. package/docs/400-reference/400-cli/300-call.md +28 -0
  85. package/docs/400-reference/400-cli/400-check.md +20 -0
  86. package/docs/400-reference/400-cli/500-ai-context.md +102 -0
  87. package/docs/400-reference/400-cli/600-docs.md +23 -0
  88. package/docs/400-reference/400-cli/700-desktop.md +35 -0
  89. package/docs/500-hosted-pages.md +31 -0
  90. package/package.json +59 -18
  91. package/src/internal/cli/app.ts +72 -0
  92. package/src/internal/cli/bin.ts +3 -0
  93. package/src/internal/cli/commands/ai.ts +44 -0
  94. package/src/internal/cli/commands/auth.ts +195 -0
  95. package/src/internal/cli/commands/call.ts +128 -0
  96. package/src/internal/cli/commands/check.ts +48 -0
  97. package/src/internal/cli/commands/desktop-installer.ts +446 -0
  98. package/src/internal/cli/commands/desktop-release.ts +190 -0
  99. package/src/internal/cli/commands/desktop.ts +47 -0
  100. package/src/internal/cli/commands/docs.ts +34 -0
  101. package/src/internal/cli/commands/serve.ts +322 -0
  102. package/src/internal/cli/environment.ts +54 -0
  103. package/src/internal/cli/login.ts +46 -0
  104. package/src/internal/cli/managed-vite.ts +267 -0
  105. package/src/internal/cli/parse-port.ts +8 -0
  106. package/src/internal/client/auth/auth-api.ts +53 -0
  107. package/src/internal/client/auth/auth-shell.css +74 -0
  108. package/src/internal/client/auth/auth-shell.tsx +167 -0
  109. package/src/internal/client/auth/entry.tsx +26 -0
  110. package/src/internal/client/auth/index.html +13 -0
  111. package/src/internal/client/background-notifications.ts +194 -0
  112. package/src/internal/client/browser-api.ts +97 -0
  113. package/src/internal/client/client-definition.ts +184 -0
  114. package/src/internal/client/commands.tsx +260 -0
  115. package/src/internal/client/host/browser-log-forwarding.ts +149 -0
  116. package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
  117. package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
  118. package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
  119. package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
  120. package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
  121. package/src/internal/client/host/notification-forwarding.ts +22 -0
  122. package/src/internal/client/host/overmux-host.tsx +200 -0
  123. package/src/internal/client/host/recovery-screen.css +75 -0
  124. package/src/internal/client/host/recovery-screen.tsx +17 -0
  125. package/src/internal/client/host/update-popover.css +82 -0
  126. package/src/internal/client/host/update-popover.tsx +166 -0
  127. package/src/internal/client/index.ts +45 -0
  128. package/src/internal/client/overmux-react.ts +283 -0
  129. package/src/internal/client/shortcuts.tsx +373 -0
  130. package/src/internal/client/theme-scope.css +114 -0
  131. package/src/internal/client/theme-scope.tsx +107 -0
  132. package/src/internal/client/transport.ts +69 -0
  133. package/src/internal/client/websocket.ts +441 -0
  134. package/src/internal/server/auth/auth-http.ts +414 -0
  135. package/src/internal/server/auth/auth-service.ts +627 -0
  136. package/src/internal/server/auth/auth-shell-http.ts +90 -0
  137. package/src/internal/server/auth/instance-control.ts +381 -0
  138. package/src/internal/server/check-overmux.ts +364 -0
  139. package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
  140. package/src/internal/server/coordinator/server-child.ts +108 -0
  141. package/src/internal/server/coordinator/server-coordinator.ts +262 -0
  142. package/src/internal/server/coordinator/update-watcher.ts +54 -0
  143. package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
  144. package/src/internal/server/http/background-notification-handlers.ts +83 -0
  145. package/src/internal/server/http/control-handlers.ts +44 -0
  146. package/src/internal/server/http/create-http-app.ts +53 -0
  147. package/src/internal/server/http/development-web-backend.ts +83 -0
  148. package/src/internal/server/http/operation-handler.ts +93 -0
  149. package/src/internal/server/http/request-logging.ts +34 -0
  150. package/src/internal/server/http/routes.ts +241 -0
  151. package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
  152. package/src/internal/server/index.ts +16 -0
  153. package/src/internal/server/notifications/background-notification-payload.ts +74 -0
  154. package/src/internal/server/notifications/background-notification-service.ts +139 -0
  155. package/src/internal/server/notifications/background-notification-store.ts +162 -0
  156. package/src/internal/server/notifications/notification-service.ts +70 -0
  157. package/src/internal/server/notifications/private-json-file.ts +84 -0
  158. package/src/internal/server/paths.ts +13 -0
  159. package/src/internal/server/rate-limiter.ts +58 -0
  160. package/src/internal/server/runtime/create-runtime.ts +87 -0
  161. package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
  162. package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
  163. package/src/internal/server/runtime/runtime-operations.ts +78 -0
  164. package/src/internal/server/runtime/runtime-resources.ts +201 -0
  165. package/src/internal/server/runtime/runtime-streams.ts +130 -0
  166. package/src/internal/server/server-logger.ts +110 -0
  167. package/src/internal/server/server-startup-options.ts +51 -0
  168. package/src/internal/server/start-application-server.ts +412 -0
  169. package/src/internal/server/start-overmux-server.ts +26 -0
  170. package/src/internal/server/test-port.ts +23 -0
  171. package/src/internal/server/vite-websocket-proxy.ts +201 -0
  172. package/src/internal/server/websocket-connection.ts +586 -0
  173. package/src/internal/server/websocket-limits.ts +2 -0
  174. package/src/internal/server/websocket-server.ts +311 -0
  175. package/src/internal/shared/background-notifications.ts +37 -0
  176. package/src/internal/shared/index.ts +2 -0
  177. package/src/internal/shared/protocol.ts +317 -0
  178. package/src/internal/shared/routes.ts +3 -0
  179. package/src/public/ai-context.ts +44 -0
  180. package/src/public/client.ts +53 -0
  181. package/src/public/config.ts +287 -0
  182. package/src/public/contracts.ts +55 -0
  183. package/src/public/index.ts +57 -0
  184. package/src/public/notifications.ts +10 -0
  185. package/src/public/server.ts +16 -0
  186. package/dist/public/core.ts +0 -119
  187. package/dist/public/protocol.ts +0 -51
  188. package/dist/public/terminal.ts +0 -23
  189. package/dist/web/assets/index-DC___IUw.css +0 -2
  190. package/dist/web/assets/index-DF9Cptse.js +0 -74
  191. package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
  192. package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
  193. package/dist/web/index.html +0 -14
@@ -0,0 +1,267 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { init, parse } from "es-module-lexer";
4
+ import {
5
+ searchForWorkspaceRoot,
6
+ type InlineConfig,
7
+ type UserConfig,
8
+ type ViteDevServer,
9
+ } from "vite";
10
+
11
+ import { viteHmrPath } from "../shared/routes";
12
+
13
+ type ViteApi = {
14
+ build: (options: { configFile: string; root: string }) => Promise<unknown>;
15
+ createServer: (options: InlineConfig) => Promise<ViteDevServer>;
16
+ loadConfigFromFile: (
17
+ environment: {
18
+ command: "build" | "serve";
19
+ mode: "development" | "production";
20
+ },
21
+ configFile: string,
22
+ configRoot: string,
23
+ ) => Promise<{ config: UserConfig } | null | undefined>;
24
+ };
25
+
26
+ type ManagedVite = {
27
+ close: () => Promise<void>;
28
+ server: ViteDevServer;
29
+ target: string;
30
+ };
31
+
32
+ export const loadViteRoot = async ({
33
+ api,
34
+ command,
35
+ configPath,
36
+ }: {
37
+ api: ViteApi;
38
+ command: "build" | "serve";
39
+ configPath: string;
40
+ }) => {
41
+ const configRoot = resolve(configPath, "..");
42
+ const mode = command === "build" ? "production" : "development";
43
+ const loaded = await api.loadConfigFromFile(
44
+ { command, mode },
45
+ configPath,
46
+ configRoot,
47
+ );
48
+ return {
49
+ config: loaded?.config ?? {},
50
+ root: resolve(configRoot, loaded?.config.root ?? "."),
51
+ };
52
+ };
53
+
54
+ const conflictingHmrKeys = [
55
+ "clientPort",
56
+ "host",
57
+ "path",
58
+ "port",
59
+ "protocol",
60
+ "server",
61
+ ] as const;
62
+
63
+ export const validateViteServerConfig = (config: UserConfig) => {
64
+ const server = config.server;
65
+ const conflict = ["host", "port", "strictPort", "https"].find(
66
+ (key) => server?.[key as keyof typeof server] !== undefined,
67
+ );
68
+ if (conflict) {
69
+ throw new Error(
70
+ `Vite server.${conflict} is managed by \`overmux serve\`; remove it from the Vite config`,
71
+ );
72
+ }
73
+ if (server?.hmr === false) {
74
+ throw new Error(
75
+ "Vite server.hmr cannot be disabled when managed by `overmux serve`",
76
+ );
77
+ }
78
+ const hmr = server?.hmr;
79
+ if (hmr && typeof hmr === "object") {
80
+ const hmrConflict = conflictingHmrKeys.find(
81
+ (key) => hmr[key] !== undefined,
82
+ );
83
+ if (hmrConflict) {
84
+ throw new Error(
85
+ `Vite server.hmr.${hmrConflict} is managed by \`overmux serve\`; remove it from the Vite config`,
86
+ );
87
+ }
88
+ }
89
+ };
90
+
91
+ const validateResolvedViteServer = (server: ViteDevServer) => {
92
+ const config = server.config.server;
93
+ const hmr =
94
+ config.hmr && typeof config.hmr === "object" ? config.hmr : undefined;
95
+ const changedHmrEndpoint = conflictingHmrKeys.some(
96
+ (key) => key !== "path" && hmr?.[key] !== undefined,
97
+ );
98
+ if (
99
+ config.host !== "127.0.0.1" ||
100
+ config.port !== 0 ||
101
+ config.strictPort !== true ||
102
+ config.https ||
103
+ hmr?.path !== viteHmrPath ||
104
+ changedHmrEndpoint
105
+ ) {
106
+ throw new Error(
107
+ "A Vite plugin changed listener or HMR settings managed by `overmux serve`",
108
+ );
109
+ }
110
+ };
111
+
112
+ const pathIsWithin = (root: string, path: string) => {
113
+ const pathFromRoot = relative(root, path);
114
+ return !pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot);
115
+ };
116
+
117
+ const localPath = async (id: string, trustedRoots: string[]) => {
118
+ if (id.includes("\0")) {
119
+ return;
120
+ }
121
+ try {
122
+ const path = await realpath(id.split("?", 1)[0]!);
123
+ if (
124
+ path.includes("node_modules") ||
125
+ !trustedRoots.some((root) => pathIsWithin(root, path))
126
+ ) {
127
+ return;
128
+ }
129
+ return path;
130
+ } catch {
131
+ return;
132
+ }
133
+ };
134
+
135
+ const importsOf = async (path: string) => {
136
+ const source = await readFile(path, "utf8");
137
+ await init;
138
+ try {
139
+ return parse(source)[0];
140
+ } catch {
141
+ return [];
142
+ }
143
+ };
144
+
145
+ const trustedFiles = async ({
146
+ configPath,
147
+ server,
148
+ trustedRoots,
149
+ }: {
150
+ configPath: string;
151
+ server: ViteDevServer;
152
+ trustedRoots: string[];
153
+ }): Promise<Set<string>> => {
154
+ const files = new Set<string>();
155
+ const visit = async (path: string): Promise<void> => {
156
+ if (files.has(path)) {
157
+ return;
158
+ }
159
+ files.add(path);
160
+ const imports = await importsOf(path);
161
+ await Promise.all(
162
+ imports.map(async (imported) => {
163
+ if (!imported.n) {
164
+ return;
165
+ }
166
+ const resolved = await server.pluginContainer.resolveId(
167
+ imported.n,
168
+ path,
169
+ );
170
+ const id = typeof resolved === "string" ? resolved : resolved?.id;
171
+ const importedPath = id ? await localPath(id, trustedRoots) : undefined;
172
+ if (importedPath) {
173
+ await visit(importedPath);
174
+ }
175
+ }),
176
+ );
177
+ };
178
+ await visit(configPath);
179
+ return files;
180
+ };
181
+
182
+ // Watch overmux.config.ts and its imports because Vite only watches browser code.
183
+ // Changes notify Overmux that its trusted server code needs reloading.
184
+ export const watchTrustedFiles = async ({
185
+ configPath,
186
+ onUpdate,
187
+ server,
188
+ }: {
189
+ configPath: string;
190
+ onUpdate: () => void;
191
+ server: ViteDevServer;
192
+ }) => {
193
+ const logicalApplicationRoot = resolve(configPath, "..");
194
+ const [applicationRoot, workspaceRoot] = await Promise.all([
195
+ realpath(logicalApplicationRoot),
196
+ realpath(searchForWorkspaceRoot(logicalApplicationRoot)),
197
+ ]);
198
+ let files = new Set<string>();
199
+ const refresh = async () => {
200
+ files = await trustedFiles({
201
+ configPath: await realpath(configPath),
202
+ server,
203
+ trustedRoots: [applicationRoot, workspaceRoot],
204
+ });
205
+ server.watcher.add([...files]);
206
+ };
207
+ await refresh();
208
+ const changed = (path: string) => {
209
+ if (files.has(path)) {
210
+ void refresh().then(onUpdate);
211
+ }
212
+ };
213
+ server.watcher.on("change", changed);
214
+ return () => {
215
+ server.watcher.off("change", changed);
216
+ };
217
+ };
218
+
219
+ export const startManagedVite = async ({
220
+ api,
221
+ configPath,
222
+ }: {
223
+ api: ViteApi;
224
+ configPath: string;
225
+ }): Promise<ManagedVite> => {
226
+ const { config, root } = await loadViteRoot({
227
+ api,
228
+ command: "serve",
229
+ configPath,
230
+ });
231
+ validateViteServerConfig(config);
232
+ const allowedRoots = [
233
+ ...(config.server?.fs?.allow ?? []),
234
+ root,
235
+ await realpath(root),
236
+ searchForWorkspaceRoot(root),
237
+ ];
238
+ const server = await api.createServer({
239
+ configFile: configPath,
240
+ root,
241
+ server: {
242
+ fs: { allow: [...new Set(allowedRoots)] },
243
+ hmr: { path: viteHmrPath },
244
+ host: "127.0.0.1",
245
+ port: 0,
246
+ strictPort: true,
247
+ },
248
+ });
249
+ try {
250
+ validateResolvedViteServer(server);
251
+ await server.listen();
252
+ const address = server.httpServer?.address();
253
+ if (!address || typeof address === "string") {
254
+ throw new Error("Private Vite server did not bind a TCP address");
255
+ }
256
+ return {
257
+ close: () => server.close(),
258
+ server,
259
+ target: `http://127.0.0.1:${address.port}`,
260
+ };
261
+ } catch (cause) {
262
+ await server.close();
263
+ throw cause;
264
+ }
265
+ };
266
+
267
+ export type { ViteApi };
@@ -0,0 +1,8 @@
1
+ import { serverConfigRuntimeSchema } from "@overmux/shared/node";
2
+
3
+ export const parsePort = (value: string): Error | number => {
4
+ const port = Number(value);
5
+ return serverConfigRuntimeSchema.shape.port.safeParse(port).success
6
+ ? port
7
+ : new Error(`Invalid port: ${value}`);
8
+ };
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ export type LoginInput = { code: string } | { ticket: string };
4
+
5
+ export type LoginResponse =
6
+ | { status: "authenticated" }
7
+ | { status: "expired" | "invalid" | "rate-limited" | "unknown" };
8
+
9
+ const authStateSchema = z.object({ authenticated: z.boolean() });
10
+ const loginFailureSchema = z.object({
11
+ status: z.enum(["expired", "invalid", "rate-limited"]),
12
+ });
13
+
14
+ const responseJson = async (response: Response) => {
15
+ try {
16
+ return await response.json();
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ };
21
+
22
+ export const getAuthState = async (signal?: AbortSignal) => {
23
+ const response = await fetch("/api/auth/state", {
24
+ cache: "no-store",
25
+ signal,
26
+ });
27
+ const state = authStateSchema.safeParse(await responseJson(response));
28
+ if (!response.ok || !state.success) {
29
+ throw new Error("Authentication state request failed");
30
+ }
31
+ return state.data;
32
+ };
33
+
34
+ export const login = async (
35
+ input: LoginInput,
36
+ signal?: AbortSignal,
37
+ ): Promise<LoginResponse> => {
38
+ const response = await fetch("/api/auth/login", {
39
+ body: JSON.stringify(input),
40
+ credentials: "same-origin",
41
+ headers: { "content-type": "application/json" },
42
+ method: "POST",
43
+ signal,
44
+ });
45
+ if (response.ok) {
46
+ return { status: "authenticated" };
47
+ }
48
+ const failure = loginFailureSchema.safeParse(await responseJson(response));
49
+ if (failure.success) {
50
+ return failure.data;
51
+ }
52
+ return { status: "unknown" };
53
+ };
@@ -0,0 +1,74 @@
1
+ :root {
2
+ color: #eee;
3
+ background: #111;
4
+ color-scheme: dark;
5
+ font:
6
+ 16px/1.5 system-ui,
7
+ sans-serif;
8
+ }
9
+
10
+ body {
11
+ display: grid;
12
+ min-height: 100vh;
13
+ margin: 0;
14
+ place-items: center;
15
+ }
16
+
17
+ [data-om-auth-shell] {
18
+ box-sizing: border-box;
19
+ width: min(26rem, calc(100vw - 3rem));
20
+ padding: 2rem;
21
+ border: 1px solid #444;
22
+ border-radius: 1rem;
23
+ background: #191919;
24
+ }
25
+
26
+ [data-om-auth-shell] h1 {
27
+ margin-top: 0;
28
+ }
29
+
30
+ [data-om-auth-shell] label {
31
+ display: block;
32
+ margin-bottom: 0.5rem;
33
+ font-weight: 600;
34
+ }
35
+
36
+ [data-om-auth-shell] input,
37
+ [data-om-auth-shell] button {
38
+ box-sizing: border-box;
39
+ width: 100%;
40
+ padding: 0.75rem;
41
+ border-radius: 0.5rem;
42
+ font: inherit;
43
+ }
44
+
45
+ [data-om-auth-shell] input {
46
+ border: 1px solid #666;
47
+ background: #111;
48
+ color: inherit;
49
+ letter-spacing: 0.08em;
50
+ text-transform: uppercase;
51
+ }
52
+
53
+ [data-om-auth-shell] button {
54
+ margin-top: 1rem;
55
+ border: 0;
56
+ background: #6d5dfc;
57
+ color: white;
58
+ font-weight: 700;
59
+ cursor: pointer;
60
+ }
61
+
62
+ [data-om-auth-shell] button:disabled {
63
+ opacity: 0.6;
64
+ cursor: wait;
65
+ }
66
+
67
+ [data-om-auth-status] {
68
+ min-height: 1.5rem;
69
+ margin-bottom: 1rem;
70
+ }
71
+
72
+ [data-om-auth-status="error"] {
73
+ color: #ff9b9b;
74
+ }
@@ -0,0 +1,167 @@
1
+ // SECURITY BOUNDARY: This directory is the public, Overmux-owned pre-authentication client.
2
+ // It may contain only locked/login/recovery UI and direct calls to the public authentication endpoints.
3
+ // It must never import userland, OvermuxHost, runtime manifests, protected application code, or user assets.
4
+ // The auth-only Vite build enforces that local imports cannot leave this directory.
5
+
6
+ import {
7
+ useCallback,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ type FormEvent,
12
+ type RefObject,
13
+ } from "react";
14
+
15
+ // React Query is intentionally excluded to keep the public pre-authentication shell lightweight.
16
+ // Its mutations may still run concurrently, so it would not replace latest-request cancellation.
17
+ import { getAuthState, login, type LoginInput } from "./auth-api";
18
+ import "./auth-shell.css";
19
+
20
+ type AuthStatus =
21
+ | "idle"
22
+ | "pending"
23
+ | "invalid"
24
+ | "expired"
25
+ | "rate-limited"
26
+ | "server-unavailable"
27
+ | "session-expired";
28
+
29
+ type AuthShellProps = {
30
+ ticket?: string;
31
+ };
32
+
33
+ type ExchangeOptions = {
34
+ input: LoginInput;
35
+ setStatus: (status: AuthStatus) => void;
36
+ signal: AbortSignal;
37
+ };
38
+
39
+ const statusMessages: Record<Exclude<AuthStatus, "idle">, string> = {
40
+ expired: "That login grant has expired. Generate a new one.",
41
+ invalid: "That login code or link is invalid.",
42
+ pending: "Signing in…",
43
+ "rate-limited": "Too many attempts. Wait a moment and try again.",
44
+ "server-unavailable": "The server is unavailable.",
45
+ "session-expired": "Your session expired or was revoked. Sign in again.",
46
+ };
47
+
48
+ const initialStatus = (): AuthStatus =>
49
+ new URLSearchParams(window.location.search).get("reason") ===
50
+ "session-expired"
51
+ ? "session-expired"
52
+ : "idle";
53
+
54
+ // Login requests are latest-wins so an older response cannot overwrite newer UI state.
55
+ // Aborting cannot undo server processing; the server separately consumes one-time grants atomically.
56
+ const nextRequest = (request: RefObject<AbortController | undefined>) => {
57
+ request.current?.abort();
58
+ const controller = new AbortController();
59
+ request.current = controller;
60
+ return controller;
61
+ };
62
+
63
+ const exchangeLogin = async ({ input, setStatus, signal }: ExchangeOptions) => {
64
+ setStatus("pending");
65
+ try {
66
+ const result = await login(input, signal);
67
+ if (signal.aborted) {
68
+ return;
69
+ }
70
+ if (result.status === "authenticated") {
71
+ window.location.replace("/");
72
+ return;
73
+ }
74
+ setStatus(
75
+ result.status === "unknown" ? "server-unavailable" : result.status,
76
+ );
77
+ } catch {
78
+ if (!signal.aborted) {
79
+ setStatus("server-unavailable");
80
+ }
81
+ }
82
+ };
83
+
84
+ const isErrorStatus = (status: AuthStatus) =>
85
+ status !== "idle" && status !== "pending";
86
+
87
+ const useAuthFlow = (ticket?: string) => {
88
+ const [status, setStatus] = useState(initialStatus);
89
+ const request = useRef<AbortController | undefined>(undefined);
90
+ const authenticate = useCallback((input: LoginInput) => {
91
+ const controller = nextRequest(request);
92
+ void exchangeLogin({ input, setStatus, signal: controller.signal });
93
+ }, []);
94
+
95
+ // Cleanup also prevents ticket changes, unmounts, and development remounts from completing stale requests.
96
+ useEffect(() => {
97
+ if (ticket) {
98
+ authenticate({ ticket });
99
+ return () => request.current?.abort();
100
+ }
101
+ const controller = nextRequest(request);
102
+ void getAuthState(controller.signal).then(
103
+ (state) => {
104
+ if (!controller.signal.aborted && state.authenticated) {
105
+ window.location.replace("/");
106
+ }
107
+ },
108
+ () => {
109
+ if (!controller.signal.aborted) {
110
+ setStatus("server-unavailable");
111
+ }
112
+ },
113
+ );
114
+ return () => request.current?.abort();
115
+ }, [authenticate, ticket]);
116
+
117
+ return { authenticate, status };
118
+ };
119
+
120
+ export const AuthShell = ({ ticket }: AuthShellProps) => {
121
+ const { authenticate, status } = useAuthFlow(ticket);
122
+ const submitCode = (event: FormEvent<HTMLFormElement>) => {
123
+ event.preventDefault();
124
+ const code = new FormData(event.currentTarget).get("code");
125
+ if (typeof code === "string") {
126
+ authenticate({ code });
127
+ }
128
+ };
129
+ const message = status === "idle" ? undefined : statusMessages[status];
130
+ const pending = status === "pending";
131
+
132
+ return (
133
+ <main aria-labelledby="auth-title" data-om-auth-shell="">
134
+ <h1 id="auth-title">Overmux is locked</h1>
135
+ <p>
136
+ Generate a login code on the server with <code>overmux auth login</code>
137
+ .
138
+ </p>
139
+ <p
140
+ aria-atomic="true"
141
+ aria-live="polite"
142
+ data-om-auth-status={isErrorStatus(status) ? "error" : ""}
143
+ id="auth-status"
144
+ role="status"
145
+ >
146
+ {message}
147
+ </p>
148
+ <form aria-busy={pending} onSubmit={submitCode}>
149
+ <label htmlFor="auth-code">Login code</label>
150
+ <input
151
+ aria-describedby="auth-status"
152
+ autoCapitalize="characters"
153
+ autoComplete="one-time-code"
154
+ disabled={pending}
155
+ id="auth-code"
156
+ name="code"
157
+ pattern="[A-Za-z0-9]{4}-[A-Za-z0-9]{4}"
158
+ required
159
+ spellCheck={false}
160
+ />
161
+ <button disabled={pending} type="submit">
162
+ Unlock Overmux
163
+ </button>
164
+ </form>
165
+ </main>
166
+ );
167
+ };
@@ -0,0 +1,26 @@
1
+ import { createRoot } from "react-dom/client";
2
+
3
+ import { AuthShell } from "./auth-shell";
4
+
5
+ // Keep extraction and URL scrubbing together so the login ticket is removed before React renders.
6
+ export const takeLoginTicket = () => {
7
+ const ticket = new URLSearchParams(window.location.hash.slice(1)).get(
8
+ "ticket",
9
+ );
10
+ if (ticket) {
11
+ window.history.replaceState(
12
+ null,
13
+ "",
14
+ `${window.location.pathname}${window.location.search}`,
15
+ );
16
+ }
17
+ return ticket ?? undefined;
18
+ };
19
+
20
+ const root = document.querySelector("#root");
21
+
22
+ if (!root) {
23
+ throw new Error("The Overmux authentication shell requires a #root element");
24
+ }
25
+
26
+ createRoot(root).render(<AuthShell ticket={takeLoginTicket()} />);
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <title>Overmux authentication</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./entry.tsx"></script>
12
+ </body>
13
+ </html>