@neuralnomads/codenomad 0.5.1 → 0.7.0

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.
@@ -1,74 +1,41 @@
1
- export type PluginEvent = {
2
- type: string
3
- properties?: Record<string, unknown>
4
- }
1
+ import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
5
2
 
6
- export type CodeNomadConfig = {
7
- instanceId: string
8
- baseUrl: string
9
- }
10
-
11
- export function getCodeNomadConfig(): CodeNomadConfig {
12
- return {
13
- instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
14
- baseUrl: requireEnv("CODENOMAD_BASE_URL"),
15
- }
16
- }
3
+ export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
17
4
 
18
5
  export function createCodeNomadClient(config: CodeNomadConfig) {
19
- return {
20
- postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
21
- startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
22
- }
23
- }
6
+ const requester = createCodeNomadRequester(config)
24
7
 
25
- function requireEnv(key: string): string {
26
- const value = process.env[key]
27
- if (!value || !value.trim()) {
28
- throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
8
+ return {
9
+ postEvent: (event: PluginEvent) =>
10
+ requester.requestVoid("/event", {
11
+ method: "POST",
12
+ body: JSON.stringify(event),
13
+ }),
14
+ startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
29
15
  }
30
- return value
31
16
  }
32
17
 
33
18
  function delay(ms: number) {
34
19
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
35
20
  }
36
21
 
37
- async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
38
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
39
- const response = await fetch(url, {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- },
44
- body: JSON.stringify(event),
45
- })
46
-
47
- if (!response.ok) {
48
- throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
49
- }
50
- }
51
-
52
- async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
53
- const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
54
-
22
+ async function startPluginEvents(
23
+ requester: ReturnType<typeof createCodeNomadRequester>,
24
+ onEvent: (event: PluginEvent) => void,
25
+ ) {
55
26
  // Fail plugin startup if we cannot establish the initial connection.
56
- const initialBody = await connectWithRetries(url, 3)
27
+ const initialBody = await connectWithRetries(requester, 3)
57
28
 
58
29
  // After startup, keep reconnecting; throw after 3 consecutive failures.
59
- void consumeWithReconnect(url, onEvent, initialBody)
30
+ void consumeWithReconnect(requester, onEvent, initialBody)
60
31
  }
61
32
 
62
- async function connectWithRetries(url: string, maxAttempts: number) {
33
+ async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
63
34
  let lastError: unknown
64
35
 
65
36
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
66
37
  try {
67
- const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
68
- if (!response.ok || !response.body) {
69
- throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
70
- }
71
- return response.body
38
+ return await requester.requestSseBody("/events")
72
39
  } catch (error) {
73
40
  lastError = error
74
41
  await delay(500 * attempt)
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
76
43
  }
77
44
 
78
45
  const reason = lastError instanceof Error ? lastError.message : String(lastError)
79
- throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
46
+ const url = requester.buildUrl("/events")
47
+ throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
80
48
  }
81
49
 
82
50
  async function consumeWithReconnect(
83
- url: string,
51
+ requester: ReturnType<typeof createCodeNomadRequester>,
84
52
  onEvent: (event: PluginEvent) => void,
85
53
  initialBody: ReadableStream<Uint8Array>,
86
54
  ) {
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
90
58
  while (true) {
91
59
  try {
92
60
  if (!body) {
93
- body = await connectWithRetries(url, 3)
61
+ body = await connectWithRetries(requester, 3)
94
62
  }
95
63
 
96
64
  await consumeSseBody(body, onEvent)
@@ -0,0 +1,124 @@
1
+ export type PluginEvent = {
2
+ type: string
3
+ properties?: Record<string, unknown>
4
+ }
5
+
6
+ export type CodeNomadConfig = {
7
+ instanceId: string
8
+ baseUrl: string
9
+ }
10
+
11
+ export function getCodeNomadConfig(): CodeNomadConfig {
12
+ return {
13
+ instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
14
+ baseUrl: requireEnv("CODENOMAD_BASE_URL"),
15
+ }
16
+ }
17
+
18
+ export function createCodeNomadRequester(config: CodeNomadConfig) {
19
+ const baseUrl = config.baseUrl.replace(/\/+$/, "")
20
+ const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
21
+ const authorization = buildInstanceAuthorizationHeader()
22
+
23
+ const buildUrl = (path: string) => {
24
+ if (path.startsWith("http://") || path.startsWith("https://")) {
25
+ return path
26
+ }
27
+ const normalized = path.startsWith("/") ? path : `/${path}`
28
+ return `${pluginBase}${normalized}`
29
+ }
30
+
31
+ const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
32
+ const output: Record<string, string> = normalizeHeaders(headers)
33
+ output.Authorization = authorization
34
+ if (hasBody) {
35
+ output["Content-Type"] = output["Content-Type"] ?? "application/json"
36
+ }
37
+ return output
38
+ }
39
+
40
+ const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
41
+ const url = buildUrl(path)
42
+ const hasBody = init?.body !== undefined
43
+ const headers = buildHeaders(init?.headers, hasBody)
44
+
45
+ return fetch(url, {
46
+ ...init,
47
+ headers,
48
+ })
49
+ }
50
+
51
+ const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
52
+ const response = await fetchWithAuth(path, init)
53
+ if (!response.ok) {
54
+ const message = await response.text().catch(() => "")
55
+ throw new Error(message || `Request failed with ${response.status}`)
56
+ }
57
+
58
+ if (response.status === 204) {
59
+ return undefined as T
60
+ }
61
+
62
+ return (await response.json()) as T
63
+ }
64
+
65
+ const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
66
+ const response = await fetchWithAuth(path, init)
67
+ if (!response.ok) {
68
+ const message = await response.text().catch(() => "")
69
+ throw new Error(message || `Request failed with ${response.status}`)
70
+ }
71
+ }
72
+
73
+ const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
74
+ const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
75
+ if (!response.ok || !response.body) {
76
+ throw new Error(`SSE unavailable (${response.status})`)
77
+ }
78
+ return response.body as ReadableStream<Uint8Array>
79
+ }
80
+
81
+ return {
82
+ buildUrl,
83
+ fetch: fetchWithAuth,
84
+ requestJson,
85
+ requestVoid,
86
+ requestSseBody,
87
+ }
88
+ }
89
+
90
+ function requireEnv(key: string): string {
91
+ const value = process.env[key]
92
+ if (!value || !value.trim()) {
93
+ throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
94
+ }
95
+ return value
96
+ }
97
+
98
+ function buildInstanceAuthorizationHeader(): string {
99
+ const username = requireEnv("OPENCODE_SERVER_USERNAME")
100
+ const password = requireEnv("OPENCODE_SERVER_PASSWORD")
101
+ const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
102
+ return `Basic ${token}`
103
+ }
104
+
105
+ function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
106
+ const output: Record<string, string> = {}
107
+ if (!headers) return output
108
+
109
+ if (headers instanceof Headers) {
110
+ headers.forEach((value, key) => {
111
+ output[key] = value
112
+ })
113
+ return output
114
+ }
115
+
116
+ if (Array.isArray(headers)) {
117
+ for (const [key, value] of headers) {
118
+ output[key] = value
119
+ }
120
+ return output
121
+ }
122
+
123
+ return { ...headers }
124
+ }
@@ -14,6 +14,8 @@ import { registerStorageRoutes } from "./routes/storage";
14
14
  import { registerPluginRoutes } from "./routes/plugin";
15
15
  import { registerBackgroundProcessRoutes } from "./routes/background-processes";
16
16
  import { BackgroundProcessManager } from "../background-processes/manager";
17
+ import { registerAuthRoutes } from "./routes/auth";
18
+ import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
17
19
  const DEFAULT_HTTP_PORT = 9898;
18
20
  export function createHttpServer(deps) {
19
21
  const app = Fastify({ logger: false });
@@ -53,8 +55,30 @@ export function createHttpServer(deps) {
53
55
  }
54
56
  done();
55
57
  });
58
+ const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]);
56
59
  app.register(cors, {
57
- origin: true,
60
+ origin: (origin, cb) => {
61
+ if (!origin) {
62
+ cb(null, true);
63
+ return;
64
+ }
65
+ let selfOrigin = null;
66
+ try {
67
+ selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin;
68
+ }
69
+ catch {
70
+ selfOrigin = null;
71
+ }
72
+ if (selfOrigin && origin === selfOrigin) {
73
+ cb(null, true);
74
+ return;
75
+ }
76
+ if (allowedDevOrigins.has(origin)) {
77
+ cb(null, true);
78
+ return;
79
+ }
80
+ cb(null, false);
81
+ },
58
82
  credentials: true,
59
83
  });
60
84
  app.register(replyFrom, {
@@ -71,6 +95,62 @@ export function createHttpServer(deps) {
71
95
  eventBus: deps.eventBus,
72
96
  logger: deps.logger.child({ component: "background-processes" }),
73
97
  });
98
+ registerAuthRoutes(app, { authManager: deps.authManager });
99
+ app.addHook("preHandler", (request, reply, done) => {
100
+ const rawUrl = request.raw.url ?? request.url;
101
+ const pathname = (rawUrl.split("?")[0] ?? "").trim();
102
+ const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]);
103
+ const publicPagePaths = new Set(["/login"]);
104
+ if (deps.authManager.isTokenBootstrapEnabled()) {
105
+ publicPagePaths.add("/auth/token");
106
+ }
107
+ if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
108
+ done();
109
+ return;
110
+ }
111
+ const session = deps.authManager.getSessionFromRequest(request);
112
+ const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/");
113
+ if (requiresAuthForApi && !session) {
114
+ // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
115
+ const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
116
+ if (pluginMatch) {
117
+ const workspaceId = pluginMatch[1];
118
+ const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
119
+ const provided = Array.isArray(request.headers.authorization)
120
+ ? request.headers.authorization[0]
121
+ : request.headers.authorization;
122
+ if (expected && provided && provided === expected) {
123
+ done();
124
+ return;
125
+ }
126
+ }
127
+ sendUnauthorized(request, reply);
128
+ return;
129
+ }
130
+ if (!session && wantsHtml(request)) {
131
+ reply.redirect("/login");
132
+ return;
133
+ }
134
+ done();
135
+ });
136
+ app.get("/", async (request, reply) => {
137
+ const session = deps.authManager.getSessionFromRequest(request);
138
+ if (!session) {
139
+ reply.redirect("/login");
140
+ return;
141
+ }
142
+ if (deps.uiDevServerUrl) {
143
+ await proxyToDevServer(request, reply, deps.uiDevServerUrl);
144
+ return;
145
+ }
146
+ const uiDir = deps.uiStaticDir;
147
+ const indexPath = path.join(uiDir, "index.html");
148
+ if (uiDir && fs.existsSync(indexPath)) {
149
+ reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"));
150
+ return;
151
+ }
152
+ reply.code(404).send({ message: "UI bundle missing" });
153
+ });
74
154
  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
75
155
  registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry });
76
156
  registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
@@ -85,10 +165,10 @@ export function createHttpServer(deps) {
85
165
  registerBackgroundProcessRoutes(app, { backgroundProcessManager });
86
166
  registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
87
167
  if (deps.uiDevServerUrl) {
88
- setupDevProxy(app, deps.uiDevServerUrl);
168
+ setupDevProxy(app, deps.uiDevServerUrl, deps.authManager);
89
169
  }
90
170
  else {
91
- setupStaticUi(app, deps.uiStaticDir);
171
+ setupStaticUi(app, deps.uiStaticDir, deps.authManager);
92
172
  }
93
173
  return {
94
174
  instance: app,
@@ -192,11 +272,18 @@ async function proxyWorkspaceRequest(args) {
192
272
  const queryIndex = (request.raw.url ?? "").indexOf("?");
193
273
  const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "";
194
274
  const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`;
275
+ const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId);
195
276
  logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance");
196
277
  if (logger.isLevelEnabled("trace")) {
197
278
  logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload");
198
279
  }
199
280
  return reply.from(targetUrl, {
281
+ rewriteRequestHeaders: (_originalRequest, headers) => {
282
+ if (instanceAuthHeader) {
283
+ headers.authorization = instanceAuthHeader;
284
+ }
285
+ return headers;
286
+ },
200
287
  onError: (proxyReply, { error }) => {
201
288
  logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request");
202
289
  if (!proxyReply.sent) {
@@ -212,7 +299,7 @@ function normalizeInstanceSuffix(pathSuffix) {
212
299
  const trimmed = pathSuffix.replace(/^\/+/, "");
213
300
  return trimmed.length === 0 ? "/" : `/${trimmed}`;
214
301
  }
215
- function setupStaticUi(app, uiDir) {
302
+ function setupStaticUi(app, uiDir, authManager) {
216
303
  if (!uiDir) {
217
304
  app.log.warn("UI static directory not provided; API endpoints only");
218
305
  return;
@@ -233,6 +320,11 @@ function setupStaticUi(app, uiDir) {
233
320
  reply.code(404).send({ message: "Not Found" });
234
321
  return;
235
322
  }
323
+ const session = authManager.getSessionFromRequest(request);
324
+ if (!session && wantsHtml(request)) {
325
+ reply.redirect("/login");
326
+ return;
327
+ }
236
328
  if (fs.existsSync(indexPath)) {
237
329
  reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"));
238
330
  }
@@ -241,7 +333,7 @@ function setupStaticUi(app, uiDir) {
241
333
  }
242
334
  });
243
335
  }
244
- function setupDevProxy(app, upstreamBase) {
336
+ function setupDevProxy(app, upstreamBase, authManager) {
245
337
  app.log.info({ upstreamBase }, "Proxying UI requests to development server");
246
338
  app.setNotFoundHandler((request, reply) => {
247
339
  const url = request.raw.url ?? "";
@@ -249,6 +341,11 @@ function setupDevProxy(app, upstreamBase) {
249
341
  reply.code(404).send({ message: "Not Found" });
250
342
  return;
251
343
  }
344
+ const session = authManager.getSessionFromRequest(request);
345
+ if (!session && wantsHtml(request)) {
346
+ reply.redirect("/login");
347
+ return;
348
+ }
252
349
  void proxyToDevServer(request, reply, upstreamBase);
253
350
  });
254
351
  }
@@ -0,0 +1,134 @@
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" />
6
+ <title>CodeNomad Login</title>
7
+ <style>
8
+ body {
9
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
10
+ background: #0b0b0f;
11
+ color: #fff;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ height: 100vh;
16
+ margin: 0;
17
+ }
18
+ .card {
19
+ width: 420px;
20
+ max-width: calc(100vw - 32px);
21
+ background: #14141c;
22
+ border: 1px solid rgba(255, 255, 255, 0.08);
23
+ border-radius: 14px;
24
+ padding: 24px;
25
+ }
26
+ h1 {
27
+ font-size: 18px;
28
+ margin: 0 0 12px;
29
+ }
30
+ p {
31
+ margin: 0 0 18px;
32
+ color: rgba(255, 255, 255, 0.7);
33
+ font-size: 13px;
34
+ line-height: 1.4;
35
+ }
36
+ label {
37
+ display: block;
38
+ font-size: 12px;
39
+ margin: 10px 0 6px;
40
+ color: rgba(255, 255, 255, 0.75);
41
+ }
42
+ input {
43
+ width: 100%;
44
+ box-sizing: border-box;
45
+ padding: 10px 12px;
46
+ border-radius: 10px;
47
+ border: 1px solid rgba(255, 255, 255, 0.12);
48
+ background: #0f0f16;
49
+ color: #fff;
50
+ }
51
+ button {
52
+ width: 100%;
53
+ margin-top: 14px;
54
+ padding: 10px 12px;
55
+ border-radius: 10px;
56
+ border: 0;
57
+ background: #4c6fff;
58
+ color: #fff;
59
+ font-weight: 600;
60
+ cursor: pointer;
61
+ }
62
+ .error {
63
+ margin-top: 12px;
64
+ color: #ff6b6b;
65
+ font-size: 13px;
66
+ }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div class="card">
71
+ <h1>Sign in</h1>
72
+ <p>This CodeNomad server is protected. Enter your credentials to continue.</p>
73
+
74
+ <label for="username">Username</label>
75
+ <input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
76
+
77
+ <label for="password">Password</label>
78
+ <input id="password" type="password" autocomplete="current-password" value="" />
79
+
80
+ <button id="submit" type="button">Continue</button>
81
+ <div id="error" class="error" style="display: none"></div>
82
+ </div>
83
+
84
+ <script>
85
+ const $ = (id) => document.getElementById(id)
86
+ const errorEl = $("error")
87
+ const showError = (msg) => {
88
+ errorEl.textContent = msg
89
+ errorEl.style.display = "block"
90
+ }
91
+ const hideError = () => {
92
+ errorEl.textContent = ""
93
+ errorEl.style.display = "none"
94
+ }
95
+
96
+ async function submit() {
97
+ hideError()
98
+ const username = $("username").value.trim()
99
+ const password = $("password").value
100
+ if (!username || !password) {
101
+ showError("Username and password are required.")
102
+ return
103
+ }
104
+ try {
105
+ const res = await fetch("/api/auth/login", {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ username, password }),
109
+ credentials: "include",
110
+ })
111
+ if (!res.ok) {
112
+ let message = ""
113
+ try {
114
+ const json = await res.json()
115
+ message = json && json.error ? String(json.error) : ""
116
+ } catch {
117
+ message = ""
118
+ }
119
+ showError(message || `Login failed (${res.status})`)
120
+ return
121
+ }
122
+ window.location.href = "/"
123
+ } catch (e) {
124
+ showError(e && e.message ? e.message : String(e))
125
+ }
126
+ }
127
+
128
+ $("submit").addEventListener("click", submit)
129
+ $("password").addEventListener("keydown", (e) => {
130
+ if (e.key === "Enter") submit()
131
+ })
132
+ </script>
133
+ </body>
134
+ </html>
@@ -0,0 +1,93 @@
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" />
6
+ <title>CodeNomad</title>
7
+ <style>
8
+ body {
9
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
10
+ background: #0b0b0f;
11
+ color: #fff;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ height: 100vh;
16
+ margin: 0;
17
+ }
18
+ .card {
19
+ width: 420px;
20
+ max-width: calc(100vw - 32px);
21
+ background: #14141c;
22
+ border: 1px solid rgba(255, 255, 255, 0.08);
23
+ border-radius: 14px;
24
+ padding: 24px;
25
+ }
26
+ h1 {
27
+ font-size: 18px;
28
+ margin: 0 0 12px;
29
+ }
30
+ p {
31
+ margin: 0;
32
+ color: rgba(255, 255, 255, 0.7);
33
+ font-size: 13px;
34
+ line-height: 1.4;
35
+ }
36
+ .error {
37
+ margin-top: 12px;
38
+ color: #ff6b6b;
39
+ font-size: 13px;
40
+ }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="card">
45
+ <h1>Connecting…</h1>
46
+ <p>Finalizing local authentication.</p>
47
+ <div id="error" class="error" style="display: none"></div>
48
+ </div>
49
+
50
+ <script>
51
+ const token = (location.hash || "").replace(/^#/, "").trim()
52
+ const errorEl = document.getElementById("error")
53
+ const showError = (msg) => {
54
+ errorEl.textContent = msg
55
+ errorEl.style.display = "block"
56
+ }
57
+
58
+ async function run() {
59
+ if (!token) {
60
+ showError("Missing bootstrap token.")
61
+ return
62
+ }
63
+
64
+ try {
65
+ const res = await fetch("/api/auth/token", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ token }),
69
+ credentials: "include",
70
+ })
71
+
72
+ if (!res.ok) {
73
+ let message = ""
74
+ try {
75
+ const json = await res.json()
76
+ message = json && json.error ? String(json.error) : ""
77
+ } catch {
78
+ message = ""
79
+ }
80
+ showError(message || `Token exchange failed (${res.status})`)
81
+ return
82
+ }
83
+
84
+ window.location.replace("/")
85
+ } catch (e) {
86
+ showError(e && e.message ? e.message : String(e))
87
+ }
88
+ }
89
+
90
+ run()
91
+ </script>
92
+ </body>
93
+ </html>