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,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");