mercuria 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -6,6 +6,7 @@ Mercuria is a world engine: a minimal static shell, a low-friction Node harness,
6
6
 
7
7
  - `mercuria build` compacts an NHFN-style range into a smaller world file for the static app.
8
8
  - `mercuria harness` serves the app and exposes a local API and event stream from one VPS-friendly process.
9
+ - the harness includes a manual-paste OpenAI OAuth flow for staff login on a headless VPS
9
10
  - `app/` is the static Netlify shell.
10
11
  - `src/harness.mjs` is the embeddable runtime surface exported by the package.
11
12
 
@@ -30,6 +31,18 @@ mercuria build \
30
31
  - `mercuria harness`
31
32
  - `mercuria serve`
32
33
  - `mercuria command "show food gaps"`
34
+ - `npm run verify:live`
35
+ - `npm run capture:live`
36
+
37
+ ## Staff login
38
+
39
+ Mercuria follows the VPS-safe manual paste flow:
40
+
41
+ 1. Click `Begin OpenAI sign-in`.
42
+ 2. Sign in with OpenAI in the browser.
43
+ 3. Copy the full redirect URL.
44
+ 4. Paste it back into the Mercuria staff panel.
45
+ 5. Complete the exchange on the harness.
33
46
 
34
47
  ## Deploy
35
48
 
package/app/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const API_HEALTH_PATH = "./api/health";
2
2
  const STATIC_WORLD_PATH = "./data/world.json";
3
+ const SESSION_STORAGE_KEY = "mercuria-session-token";
3
4
  const BOUNDS = {
4
5
  west: -141,
5
6
  east: -52,
@@ -13,14 +14,35 @@ const state = {
13
14
  focusId: null,
14
15
  filterIds: [],
15
16
  regionCode: null,
16
- thread: []
17
+ thread: [],
18
+ auth: {
19
+ available: false,
20
+ loggedIn: false,
21
+ session: null,
22
+ panelOpen: false,
23
+ flow: null,
24
+ error: ""
25
+ }
17
26
  };
18
27
 
19
28
  const refs = {
20
29
  shell: document.querySelector(".app-shell"),
21
30
  worldCaption: document.getElementById("world-caption"),
22
31
  presence: document.getElementById("presence-pill"),
32
+ identityButton: document.getElementById("identity-button"),
33
+ identityPill: document.getElementById("identity-pill"),
23
34
  surfaceDeck: document.getElementById("surface-deck"),
35
+ authPanel: document.getElementById("auth-panel"),
36
+ authPanelMeta: document.getElementById("auth-panel-meta"),
37
+ authCopy: document.getElementById("auth-copy"),
38
+ authStatusLine: document.getElementById("auth-status-line"),
39
+ authLink: document.getElementById("auth-link"),
40
+ authFlowMeta: document.getElementById("auth-flow-meta"),
41
+ authPaste: document.getElementById("auth-paste"),
42
+ authBegin: document.getElementById("auth-begin"),
43
+ authRefresh: document.getElementById("auth-refresh"),
44
+ authLogout: document.getElementById("auth-logout"),
45
+ authFinish: document.getElementById("auth-finish"),
24
46
  regionStrip: document.getElementById("region-strip"),
25
47
  focusStat: document.getElementById("focus-stat"),
26
48
  signalStat: document.getElementById("signal-stat"),
@@ -50,6 +72,41 @@ let familyMap = new Map();
50
72
  let communityMap = new Map();
51
73
  let resizeFrame = null;
52
74
 
75
+ function readSessionToken() {
76
+ try {
77
+ return window.localStorage.getItem(SESSION_STORAGE_KEY) || "";
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
83
+ function writeSessionToken(token) {
84
+ try {
85
+ if (token) window.localStorage.setItem(SESSION_STORAGE_KEY, token);
86
+ else window.localStorage.removeItem(SESSION_STORAGE_KEY);
87
+ } catch {}
88
+ }
89
+
90
+ function sessionHeaders(initial = {}) {
91
+ const headers = new Headers(initial);
92
+ const token = readSessionToken();
93
+ if (token) headers.set("X-Mercuria-Session", token);
94
+ return headers;
95
+ }
96
+
97
+ async function fetchApiJson(url, options = {}) {
98
+ const response = await fetch(url, {
99
+ cache: "no-store",
100
+ ...options,
101
+ headers: sessionHeaders(options.headers || {})
102
+ });
103
+ const payload = await response.json().catch(() => ({}));
104
+ if (!response.ok) {
105
+ throw new Error(payload.error || `API request failed: ${response.status}`);
106
+ }
107
+ return payload;
108
+ }
109
+
53
110
  function normalize(value) {
54
111
  return String(value || "")
55
112
  .toLowerCase()
@@ -131,11 +188,14 @@ async function maybeFetchApiMode() {
131
188
  async function loadWorld() {
132
189
  state.apiMode = await maybeFetchApiMode();
133
190
  const worldUrl = state.apiMode ? "./api/world" : STATIC_WORLD_PATH;
134
- const response = await fetch(worldUrl, { cache: "no-store" });
135
- if (!response.ok) {
136
- throw new Error(`Could not load world file from ${worldUrl}`);
137
- }
138
- state.world = await response.json();
191
+ state.world = state.apiMode
192
+ ? await fetchApiJson(worldUrl)
193
+ : await fetch(worldUrl, { cache: "no-store" }).then(async (response) => {
194
+ if (!response.ok) {
195
+ throw new Error(`Could not load world file from ${worldUrl}`);
196
+ }
197
+ return response.json();
198
+ });
139
199
  buildCollections();
140
200
  state.filterIds = state.world.communities.map((community) => community.id);
141
201
  state.focusId = state.world.hotlist.signalLeaders[0] || state.world.communities[0]?.id || null;
@@ -144,6 +204,33 @@ async function loadWorld() {
144
204
  "Mercuria online",
145
205
  `${state.world.meta.range} loaded with ${state.world.meta.counts.communities} communities and ${state.world.meta.counts.programs} programs.`
146
206
  );
207
+ await refreshAuthStatus();
208
+ }
209
+
210
+ async function refreshAuthStatus() {
211
+ if (!state.apiMode) {
212
+ state.auth.available = false;
213
+ state.auth.loggedIn = false;
214
+ state.auth.session = null;
215
+ state.auth.error = "";
216
+ return;
217
+ }
218
+
219
+ try {
220
+ const payload = await fetchApiJson("./api/auth/status");
221
+ state.auth.available = Boolean(payload.available);
222
+ state.auth.loggedIn = Boolean(payload.loggedIn);
223
+ state.auth.session = payload.session || null;
224
+ state.auth.error = "";
225
+ if (!payload.loggedIn) {
226
+ writeSessionToken("");
227
+ }
228
+ } catch (error) {
229
+ state.auth.available = false;
230
+ state.auth.loggedIn = false;
231
+ state.auth.session = null;
232
+ state.auth.error = error.message;
233
+ }
147
234
  }
148
235
 
149
236
  function runLocalCommand(input) {
@@ -319,18 +406,97 @@ function runLocalCommand(input) {
319
406
  }
320
407
 
321
408
  async function issueCommand(input) {
322
- const payload = state.apiMode
323
- ? await fetch("./api/command", {
409
+ try {
410
+ const payload = state.apiMode
411
+ ? await fetchApiJson("./api/command", {
412
+ method: "POST",
413
+ headers: { "Content-Type": "application/json" },
414
+ body: JSON.stringify({ input })
415
+ })
416
+ : runLocalCommand(input);
417
+
418
+ state.filterIds = payload.filterIds || payload.state?.filterIds || state.filterIds;
419
+ state.focusId = payload.focusId || payload.state?.focusId || state.focusId;
420
+ state.regionCode = payload.regionCode || payload.state?.regionCode || null;
421
+ appendThread("command", input, payload.reply);
422
+ } catch (error) {
423
+ appendThread("error", input, error.message);
424
+ }
425
+ renderAll();
426
+ }
427
+
428
+ async function beginAuth() {
429
+ try {
430
+ if (!state.apiMode) {
431
+ throw new Error("Attach the Mercuria harness to enable operator login.");
432
+ }
433
+ const payload = await fetchApiJson("./api/auth/openai/begin", {
434
+ method: "POST",
435
+ headers: { "Content-Type": "application/json" },
436
+ body: JSON.stringify({ profile: "operator" })
437
+ });
438
+ state.auth.flow = payload;
439
+ state.auth.error = "";
440
+ state.auth.panelOpen = true;
441
+ appendThread("auth", "OpenAI sign-in prepared", "Open the sign-in link, finish in the browser, then paste the redirect URL back here.");
442
+ } catch (error) {
443
+ state.auth.error = error.message;
444
+ appendThread("auth", "Sign-in preparation failed", error.message);
445
+ }
446
+ renderAll();
447
+ }
448
+
449
+ async function finishAuth() {
450
+ try {
451
+ const redirectUrl = refs.authPaste.value.trim();
452
+ if (!redirectUrl) {
453
+ throw new Error("Paste the full redirect URL from the browser first.");
454
+ }
455
+ const payload = await fetchApiJson("./api/auth/openai/finish", {
456
+ method: "POST",
457
+ headers: { "Content-Type": "application/json" },
458
+ body: JSON.stringify({
459
+ profile: "operator",
460
+ redirectUrl
461
+ })
462
+ });
463
+ if (payload.sessionToken) {
464
+ writeSessionToken(payload.sessionToken);
465
+ }
466
+ state.auth.loggedIn = true;
467
+ state.auth.session = payload.session || null;
468
+ state.auth.flow = null;
469
+ state.auth.error = "";
470
+ refs.authPaste.value = "";
471
+ appendThread("auth", "OpenAI sign-in completed", `Operator session linked to account ${payload.accountId}.`);
472
+ } catch (error) {
473
+ state.auth.error = error.message;
474
+ appendThread("auth", "Sign-in completion failed", error.message);
475
+ }
476
+ await refreshAuthStatus();
477
+ renderAll();
478
+ }
479
+
480
+ async function logoutAuth() {
481
+ try {
482
+ if (state.apiMode) {
483
+ await fetchApiJson("./api/auth/logout", {
324
484
  method: "POST",
325
- headers: { "Content-Type": "application/json" },
326
- body: JSON.stringify({ input })
327
- }).then((response) => response.json())
328
- : runLocalCommand(input);
329
-
330
- state.filterIds = payload.filterIds || payload.state?.filterIds || state.filterIds;
331
- state.focusId = payload.focusId || payload.state?.focusId || state.focusId;
332
- state.regionCode = payload.regionCode || payload.state?.regionCode || null;
333
- appendThread("command", input, payload.reply);
485
+ headers: { "Content-Type": "application/json" }
486
+ });
487
+ }
488
+ writeSessionToken("");
489
+ state.auth.loggedIn = false;
490
+ state.auth.session = null;
491
+ state.auth.flow = null;
492
+ state.auth.error = "";
493
+ refs.authPaste.value = "";
494
+ appendThread("auth", "Operator session cleared", "Mercuria returned to guest mode.");
495
+ } catch (error) {
496
+ state.auth.error = error.message;
497
+ appendThread("auth", "Logout failed", error.message);
498
+ }
499
+ await refreshAuthStatus();
334
500
  renderAll();
335
501
  }
336
502
 
@@ -349,6 +515,44 @@ function renderSuggestions() {
349
515
  }
350
516
  }
351
517
 
518
+ function renderAuth() {
519
+ const panelVisible = state.auth.panelOpen;
520
+ refs.authPanel.hidden = !panelVisible;
521
+ refs.identityPill.textContent = state.auth.loggedIn
522
+ ? `Staff linked · ${state.auth.session?.accountId || "operator"}`
523
+ : state.apiMode
524
+ ? "Staff login"
525
+ : "Guest mode";
526
+
527
+ refs.authPanelMeta.textContent = state.auth.loggedIn ? "linked" : "manual paste";
528
+ refs.authCopy.textContent = state.auth.loggedIn
529
+ ? "Mercuria is carrying an active operator identity through the VPS-safe OpenAI flow."
530
+ : "Mercuria can attach a live operator identity through the VPS-safe OpenAI copy-paste OAuth flow.";
531
+ refs.authStatusLine.textContent = state.auth.error
532
+ ? state.auth.error
533
+ : state.auth.loggedIn
534
+ ? `Linked to ${state.auth.session?.accountId || "operator"} until ${new Date(state.auth.session?.expiresAt || Date.now()).toISOString()}.`
535
+ : state.apiMode
536
+ ? "Harness attached. Begin the OpenAI sign-in flow to link a staff operator."
537
+ : "Static shell mode. Start the harness to unlock staff login.";
538
+
539
+ if (state.auth.flow?.authUrl) {
540
+ refs.authLink.hidden = false;
541
+ refs.authLink.href = state.auth.flow.authUrl;
542
+ refs.authLink.textContent = "Open the OpenAI sign-in link";
543
+ refs.authFlowMeta.textContent = `Redirect URI: ${state.auth.flow.redirectUri} · state ${state.auth.flow.state}`;
544
+ } else {
545
+ refs.authLink.hidden = true;
546
+ refs.authLink.href = "#";
547
+ refs.authFlowMeta.textContent = state.auth.loggedIn
548
+ ? "Operator session is active."
549
+ : "No active sign-in flow.";
550
+ }
551
+
552
+ refs.authLogout.disabled = !state.auth.loggedIn;
553
+ refs.authFinish.disabled = !state.apiMode;
554
+ }
555
+
352
556
  function renderThread() {
353
557
  refs.threadLog.innerHTML = "";
354
558
  for (const entry of state.thread) {
@@ -629,6 +833,7 @@ function renderTopline() {
629
833
 
630
834
  function renderAll() {
631
835
  renderTopline();
836
+ renderAuth();
632
837
  renderRegionStrip();
633
838
  renderThread();
634
839
  renderFocus();
@@ -664,6 +869,10 @@ function handleResize() {
664
869
 
665
870
  function bindEvents() {
666
871
  window.addEventListener("resize", handleResize);
872
+ refs.identityButton.addEventListener("click", () => {
873
+ state.auth.panelOpen = !state.auth.panelOpen;
874
+ renderAuth();
875
+ });
667
876
  refs.terrain.addEventListener("click", (event) => {
668
877
  const winner = nearestCommunity(event);
669
878
  if (!winner) return;
@@ -681,6 +890,13 @@ function bindEvents() {
681
890
  issueCommand(input);
682
891
  refs.commandInput.value = "";
683
892
  });
893
+ refs.authBegin.addEventListener("click", () => beginAuth());
894
+ refs.authRefresh.addEventListener("click", async () => {
895
+ await refreshAuthStatus();
896
+ renderAll();
897
+ });
898
+ refs.authLogout.addEventListener("click", () => logoutAuth());
899
+ refs.authFinish.addEventListener("click", () => finishAuth());
684
900
  }
685
901
 
686
902
  async function bootstrap() {
package/app/index.html CHANGED
@@ -22,9 +22,33 @@
22
22
  <div class="topline-meta">
23
23
  <div id="world-caption" class="meta-pill">Loading range</div>
24
24
  <div id="presence-pill" class="meta-pill meta-pill--ghost">Static shell</div>
25
+ <button id="identity-button" class="meta-pill meta-pill--button" type="button">
26
+ <span id="identity-pill">Guest mode</span>
27
+ </button>
25
28
  </div>
26
29
  </header>
27
30
 
31
+ <section id="auth-panel" class="auth-panel panel" hidden>
32
+ <div class="panel-heading">
33
+ <span>Staff login</span>
34
+ <span id="auth-panel-meta" class="panel-heading__meta">manual paste</span>
35
+ </div>
36
+ <p id="auth-copy" class="body-copy">
37
+ Mercuria can attach a live operator identity through the VPS-safe OpenAI copy-paste OAuth flow.
38
+ </p>
39
+ <div id="auth-status-line" class="auth-status-line">Checking auth surface.</div>
40
+ <div class="auth-actions">
41
+ <button id="auth-begin" type="button">Begin OpenAI sign-in</button>
42
+ <button id="auth-refresh" type="button">Refresh status</button>
43
+ <button id="auth-logout" type="button">Logout</button>
44
+ </div>
45
+ <a id="auth-link" class="auth-link" href="#" target="_blank" rel="noreferrer" hidden>Open the OpenAI sign-in link</a>
46
+ <div id="auth-flow-meta" class="auth-flow-meta"></div>
47
+ <label class="auth-paste-label" for="auth-paste">Paste the full redirect URL after sign-in</label>
48
+ <textarea id="auth-paste" rows="3" placeholder="http://localhost:1455/auth/callback?code=...&state=..."></textarea>
49
+ <button id="auth-finish" class="auth-finish" type="button">Complete sign-in</button>
50
+ </section>
51
+
28
52
  <main class="workspace">
29
53
  <section class="surface-panel">
30
54
  <canvas id="terrain" aria-label="Mercuria terrain"></canvas>
package/app/styles.css CHANGED
@@ -112,10 +112,92 @@ body {
112
112
  border: 1px solid var(--line);
113
113
  }
114
114
 
115
+ .meta-pill--button {
116
+ color: var(--text);
117
+ cursor: pointer;
118
+ font: inherit;
119
+ }
120
+
121
+ .meta-pill--button:hover,
122
+ .meta-pill--button:focus-visible {
123
+ border-color: var(--line-strong);
124
+ background: rgba(20, 33, 27, 0.84);
125
+ }
126
+
115
127
  .meta-pill--ghost {
116
128
  background: rgba(16, 27, 21, 0.34);
117
129
  }
118
130
 
131
+ .auth-panel {
132
+ margin-bottom: 18px;
133
+ gap: 14px;
134
+ }
135
+
136
+ .auth-status-line,
137
+ .auth-flow-meta {
138
+ padding: 12px 14px;
139
+ border-radius: 16px;
140
+ border: 1px solid var(--line);
141
+ background: rgba(8, 14, 11, 0.7);
142
+ color: var(--muted);
143
+ }
144
+
145
+ .auth-actions {
146
+ display: flex;
147
+ flex-wrap: wrap;
148
+ gap: 10px;
149
+ }
150
+
151
+ .auth-actions button,
152
+ .auth-finish {
153
+ padding: 11px 15px;
154
+ border-radius: var(--radius-sm);
155
+ border: 1px solid var(--line);
156
+ background: rgba(11, 19, 15, 0.82);
157
+ color: var(--text);
158
+ cursor: pointer;
159
+ font: inherit;
160
+ }
161
+
162
+ .auth-actions button:hover,
163
+ .auth-actions button:focus-visible,
164
+ .auth-finish:hover,
165
+ .auth-finish:focus-visible {
166
+ border-color: var(--line-strong);
167
+ background: rgba(20, 33, 27, 0.84);
168
+ }
169
+
170
+ .auth-actions button:disabled,
171
+ .auth-finish:disabled {
172
+ opacity: 0.45;
173
+ cursor: not-allowed;
174
+ }
175
+
176
+ .auth-link {
177
+ color: var(--accent);
178
+ text-decoration: none;
179
+ font-size: 0.95rem;
180
+ }
181
+
182
+ .auth-paste-label {
183
+ color: var(--muted);
184
+ font-size: 0.78rem;
185
+ letter-spacing: 0.12em;
186
+ text-transform: uppercase;
187
+ }
188
+
189
+ #auth-paste {
190
+ width: 100%;
191
+ resize: vertical;
192
+ min-height: 88px;
193
+ padding: 14px;
194
+ border-radius: 18px;
195
+ border: 1px solid var(--line);
196
+ background: rgba(8, 14, 11, 0.78);
197
+ color: var(--text);
198
+ font: inherit;
199
+ }
200
+
119
201
  .workspace {
120
202
  display: grid;
121
203
  grid-template-columns: minmax(0, 1.5fr) minmax(320px, 420px);
package/netlify.toml CHANGED
@@ -1,6 +1,12 @@
1
1
  [build]
2
2
  publish = "app"
3
3
 
4
+ [[redirects]]
5
+ from = "/api/*"
6
+ to = "https://dashboard.lmtlssss.fun/api/:splat"
7
+ status = 200
8
+ force = true
9
+
4
10
  [[headers]]
5
11
  for = "/*"
6
12
  [headers.values]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercuria",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Mercuria world engine CLI and harness.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "harness": "node ./bin/mercuria.js harness",
25
25
  "serve": "node ./bin/mercuria.js harness",
26
26
  "verify:local": "node ./tests/verify-site.mjs http://127.0.0.1:4177",
27
- "verify:live": "node ./tests/verify-site.mjs https://nfndashboard.netlify.app"
27
+ "verify:live": "node ./tests/verify-site.mjs https://nfndashboard.netlify.app",
28
+ "capture:live": "node ./tests/capture-matrix.mjs https://nfndashboard.netlify.app"
28
29
  },
29
30
  "keywords": [
30
31
  "cli",
package/src/harness.mjs CHANGED
@@ -1,9 +1,17 @@
1
1
  import http from "node:http";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
 
6
7
  import { defaultState, runCommand } from "./shared/commands.mjs";
8
+ import {
9
+ beginOpenAILogin,
10
+ finishOpenAILogin,
11
+ readOpenAIProfile,
12
+ refreshOpenAIProfile
13
+ } from "./shared/openai-auth.mjs";
14
+ import { readSessionStore, writeSessionStore } from "./shared/oauth-store.mjs";
7
15
  import { readJson } from "./shared/world.mjs";
8
16
 
9
17
  const MIME_TYPES = {
@@ -21,7 +29,9 @@ function responseJson(res, status, body) {
21
29
  res.writeHead(status, {
22
30
  "Content-Type": "application/json; charset=utf-8",
23
31
  "Cache-Control": "no-store",
24
- "Access-Control-Allow-Origin": "*"
32
+ "Access-Control-Allow-Origin": "*",
33
+ "Access-Control-Allow-Headers": "Content-Type, X-Mercuria-Session",
34
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS"
25
35
  });
26
36
  res.end(`${JSON.stringify(body)}\n`);
27
37
  }
@@ -44,6 +54,23 @@ async function readBody(req) {
44
54
  return Buffer.concat(chunks).toString("utf8");
45
55
  }
46
56
 
57
+ function sessionToken() {
58
+ return randomBytes(24).toString("base64url");
59
+ }
60
+
61
+ function sessionHeader(req) {
62
+ return req.headers["x-mercuria-session"] || null;
63
+ }
64
+
65
+ function summarizeSession(record) {
66
+ return {
67
+ accountId: record.accountId,
68
+ profile: record.profile,
69
+ createdAt: record.createdAt,
70
+ expiresAt: record.expiresAt
71
+ };
72
+ }
73
+
47
74
  export async function createHarness({
48
75
  worldPath,
49
76
  siteDir,
@@ -60,6 +87,8 @@ export async function createHarness({
60
87
  }
61
88
  ];
62
89
  const clients = new Set();
90
+ const storedSessions = await readSessionStore();
91
+ const sessions = new Map(Object.entries(storedSessions.tokens || {}));
63
92
 
64
93
  function broadcast(event) {
65
94
  const payload = `data: ${JSON.stringify(event)}\n\n`;
@@ -69,6 +98,42 @@ export async function createHarness({
69
98
  onEvent(event);
70
99
  }
71
100
 
101
+ async function persistSessions() {
102
+ await writeSessionStore({
103
+ tokens: Object.fromEntries(sessions.entries())
104
+ });
105
+ }
106
+
107
+ function getSession(req) {
108
+ const token = sessionHeader(req);
109
+ if (!token) return null;
110
+ const record = sessions.get(token);
111
+ if (!record) return null;
112
+ if (record.expiresAt && record.expiresAt <= Date.now()) {
113
+ sessions.delete(token);
114
+ persistSessions().catch(() => {});
115
+ return null;
116
+ }
117
+ return { token, record };
118
+ }
119
+
120
+ async function createSession(profile) {
121
+ const oauth = await readOpenAIProfile(profile);
122
+ if (!oauth) {
123
+ throw new Error(`No OAuth profile saved for "${profile}"`);
124
+ }
125
+ const token = sessionToken();
126
+ const record = {
127
+ profile,
128
+ accountId: oauth.account_id,
129
+ createdAt: new Date().toISOString(),
130
+ expiresAt: oauth.expires_at
131
+ };
132
+ sessions.set(token, record);
133
+ await persistSessions();
134
+ return { token, record };
135
+ }
136
+
72
137
  const server = http.createServer(async (req, res) => {
73
138
  try {
74
139
  const url = new URL(req.url || "/", "http://127.0.0.1");
@@ -77,14 +142,22 @@ export async function createHarness({
77
142
  res.writeHead(204, {
78
143
  "Access-Control-Allow-Origin": "*",
79
144
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
80
- "Access-Control-Allow-Headers": "Content-Type"
145
+ "Access-Control-Allow-Headers": "Content-Type, X-Mercuria-Session"
81
146
  });
82
147
  res.end();
83
148
  return;
84
149
  }
85
150
 
86
151
  if (url.pathname === "/api/health") {
87
- responseJson(res, 200, { ok: true, mode: "harness", range: world.meta.range });
152
+ responseJson(res, 200, {
153
+ ok: true,
154
+ mode: "harness",
155
+ range: world.meta.range,
156
+ auth: {
157
+ provider: "openai-codex",
158
+ manualPaste: true
159
+ }
160
+ });
88
161
  return;
89
162
  }
90
163
 
@@ -98,12 +171,101 @@ export async function createHarness({
98
171
  return;
99
172
  }
100
173
 
174
+ if (url.pathname === "/api/auth/status") {
175
+ const active = getSession(req);
176
+ responseJson(res, 200, {
177
+ available: true,
178
+ loggedIn: Boolean(active),
179
+ session: active ? summarizeSession(active.record) : null
180
+ });
181
+ return;
182
+ }
183
+
184
+ if (url.pathname === "/api/auth/openai/begin" && req.method === "POST") {
185
+ const body = await readBody(req);
186
+ const payload = body ? JSON.parse(body) : {};
187
+ const result = await beginOpenAILogin({
188
+ profile: payload.profile || "operator",
189
+ originator: payload.originator || "mercuria",
190
+ redirectUri: payload.redirectUri
191
+ });
192
+ const event = {
193
+ kind: "auth",
194
+ title: "OpenAI sign-in prepared",
195
+ body: `Manual paste flow prepared for profile ${result.profile}.`,
196
+ at: new Date().toISOString()
197
+ };
198
+ ledger.push(event);
199
+ ledger.splice(0, Math.max(0, ledger.length - 80));
200
+ broadcast(event);
201
+ responseJson(res, 200, result);
202
+ return;
203
+ }
204
+
205
+ if (url.pathname === "/api/auth/openai/finish" && req.method === "POST") {
206
+ const body = await readBody(req);
207
+ const payload = body ? JSON.parse(body) : {};
208
+ const result = await finishOpenAILogin({
209
+ profile: payload.profile || "operator",
210
+ redirectUrl: payload.redirectUrl
211
+ });
212
+ const session = await createSession(payload.profile || "operator");
213
+ const event = {
214
+ kind: "auth",
215
+ title: "OpenAI sign-in completed",
216
+ body: `Mercuria operator linked to account ${result.accountId}.`,
217
+ at: new Date().toISOString()
218
+ };
219
+ ledger.push(event);
220
+ ledger.splice(0, Math.max(0, ledger.length - 80));
221
+ broadcast(event);
222
+ responseJson(res, 200, {
223
+ ok: true,
224
+ accountId: result.accountId,
225
+ expiresAt: result.expiresAt,
226
+ sessionToken: session.token,
227
+ session: summarizeSession(session.record)
228
+ });
229
+ return;
230
+ }
231
+
232
+ if (url.pathname === "/api/auth/openai/refresh" && req.method === "POST") {
233
+ const active = getSession(req);
234
+ if (!active) {
235
+ responseJson(res, 401, { ok: false, error: "No active staff session" });
236
+ return;
237
+ }
238
+ const refreshed = await refreshOpenAIProfile(active.record.profile);
239
+ const renewed = await createSession(active.record.profile);
240
+ sessions.delete(active.token);
241
+ await persistSessions();
242
+ responseJson(res, 200, {
243
+ ok: true,
244
+ accountId: refreshed.accountId,
245
+ expiresAt: refreshed.expiresAt,
246
+ sessionToken: renewed.token,
247
+ session: summarizeSession(renewed.record)
248
+ });
249
+ return;
250
+ }
251
+
252
+ if (url.pathname === "/api/auth/logout" && req.method === "POST") {
253
+ const active = getSession(req);
254
+ if (active) {
255
+ sessions.delete(active.token);
256
+ await persistSessions();
257
+ }
258
+ responseJson(res, 200, { ok: true });
259
+ return;
260
+ }
261
+
101
262
  if (url.pathname === "/api/stream") {
102
263
  res.writeHead(200, {
103
264
  "Content-Type": "text/event-stream; charset=utf-8",
104
265
  "Cache-Control": "no-cache, no-transform",
105
266
  Connection: "keep-alive",
106
- "Access-Control-Allow-Origin": "*"
267
+ "Access-Control-Allow-Origin": "*",
268
+ "Access-Control-Allow-Headers": "Content-Type, X-Mercuria-Session"
107
269
  });
108
270
  res.write(`data: ${JSON.stringify({ kind: "sync", events: ledger.slice(-16) })}\n\n`);
109
271
  clients.add(res);
package/src/index.mjs CHANGED
@@ -1,3 +1,10 @@
1
1
  export { createHarness } from "./harness.mjs";
2
2
  export { buildWorldFile, buildWorldFromNhfn, readJson, summarizeWorld, writeJson } from "./shared/world.mjs";
3
3
  export { defaultState, runCommand } from "./shared/commands.mjs";
4
+ export {
5
+ beginOpenAILogin,
6
+ finishOpenAILogin,
7
+ refreshOpenAIProfile,
8
+ readOpenAIProfile,
9
+ parseAuthorizationInput
10
+ } from "./shared/openai-auth.mjs";
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ async function ensureDir(dirPath) {
6
+ await fs.mkdir(dirPath, { recursive: true });
7
+ }
8
+
9
+ function rootDir() {
10
+ return path.join(os.homedir(), ".config", "mercuria");
11
+ }
12
+
13
+ function oauthDir() {
14
+ return path.join(rootDir(), "oauth");
15
+ }
16
+
17
+ function sessionDir() {
18
+ return path.join(rootDir(), "sessions");
19
+ }
20
+
21
+ export function oauthProfilePath(profile = "default") {
22
+ return path.join(oauthDir(), `${profile}.json`);
23
+ }
24
+
25
+ export function oauthPendingPath(profile = "default") {
26
+ return path.join(oauthDir(), `${profile}.pending.json`);
27
+ }
28
+
29
+ export function sessionStorePath() {
30
+ return path.join(sessionDir(), "tokens.json");
31
+ }
32
+
33
+ export async function readJsonFile(filePath) {
34
+ try {
35
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export async function writeJsonFile(filePath, value) {
42
+ await ensureDir(path.dirname(filePath));
43
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
44
+ return filePath;
45
+ }
46
+
47
+ export async function deleteFile(filePath) {
48
+ await fs.rm(filePath, { force: true });
49
+ }
50
+
51
+ export async function readOAuthProfile(profile = "default") {
52
+ return readJsonFile(oauthProfilePath(profile));
53
+ }
54
+
55
+ export async function writeOAuthProfile(profile, value) {
56
+ return writeJsonFile(oauthProfilePath(profile), value);
57
+ }
58
+
59
+ export async function readOAuthPending(profile = "default") {
60
+ return readJsonFile(oauthPendingPath(profile));
61
+ }
62
+
63
+ export async function writeOAuthPending(profile, value) {
64
+ return writeJsonFile(oauthPendingPath(profile), value);
65
+ }
66
+
67
+ export async function deleteOAuthPending(profile = "default") {
68
+ return deleteFile(oauthPendingPath(profile));
69
+ }
70
+
71
+ export async function readSessionStore() {
72
+ const current = await readJsonFile(sessionStorePath());
73
+ return current && typeof current === "object" ? current : { tokens: {} };
74
+ }
75
+
76
+ export async function writeSessionStore(value) {
77
+ return writeJsonFile(sessionStorePath(), value);
78
+ }
@@ -0,0 +1,250 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import {
4
+ deleteOAuthPending,
5
+ readOAuthPending,
6
+ readOAuthProfile,
7
+ writeOAuthPending,
8
+ writeOAuthProfile
9
+ } from "./oauth-store.mjs";
10
+
11
+ export const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
12
+ export const OPENAI_CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
13
+ export const OPENAI_CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
14
+ export const OPENAI_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback";
15
+ export const OPENAI_CODEX_SCOPE = "openid profile email offline_access";
16
+ export const OPENAI_CODEX_JWT_CLAIM_PATH = "https://api.openai.com/auth";
17
+
18
+ function base64Url(buffer) {
19
+ return buffer.toString("base64url");
20
+ }
21
+
22
+ function buildPkce() {
23
+ const verifier = base64Url(randomBytes(32));
24
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
25
+ return { verifier, challenge };
26
+ }
27
+
28
+ function createState() {
29
+ return randomBytes(16).toString("hex");
30
+ }
31
+
32
+ function now() {
33
+ return Date.now();
34
+ }
35
+
36
+ function normalizeExpiresAt(expiresInSeconds) {
37
+ const expiresIn = Number(expiresInSeconds);
38
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
39
+ throw new Error("OpenAI token response missing valid expires_in");
40
+ }
41
+ return now() + expiresIn * 1000;
42
+ }
43
+
44
+ function decodeJwtPayload(token) {
45
+ try {
46
+ const parts = String(token || "").split(".");
47
+ if (parts.length !== 3) return null;
48
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export function extractOpenAIAccountId(accessToken) {
55
+ const payload = decodeJwtPayload(accessToken);
56
+ const accountId = payload?.[OPENAI_CODEX_JWT_CLAIM_PATH]?.chatgpt_account_id;
57
+ return typeof accountId === "string" && accountId ? accountId : null;
58
+ }
59
+
60
+ export function createAuthorizationFlow({ originator = "mercuria", redirectUri = OPENAI_CODEX_REDIRECT_URI } = {}) {
61
+ const { verifier, challenge } = buildPkce();
62
+ const state = createState();
63
+ const url = new URL(OPENAI_CODEX_AUTHORIZE_URL);
64
+ url.searchParams.set("response_type", "code");
65
+ url.searchParams.set("client_id", OPENAI_CODEX_CLIENT_ID);
66
+ url.searchParams.set("redirect_uri", redirectUri);
67
+ url.searchParams.set("scope", OPENAI_CODEX_SCOPE);
68
+ url.searchParams.set("code_challenge", challenge);
69
+ url.searchParams.set("code_challenge_method", "S256");
70
+ url.searchParams.set("state", state);
71
+ url.searchParams.set("id_token_add_organizations", "true");
72
+ url.searchParams.set("codex_cli_simplified_flow", "true");
73
+ url.searchParams.set("originator", originator);
74
+ return {
75
+ verifier,
76
+ state,
77
+ url: url.toString(),
78
+ originator,
79
+ redirectUri
80
+ };
81
+ }
82
+
83
+ export function parseAuthorizationInput(input) {
84
+ const value = String(input || "").trim();
85
+ if (!value) return {};
86
+
87
+ try {
88
+ const url = new URL(value);
89
+ return {
90
+ code: url.searchParams.get("code") || undefined,
91
+ state: url.searchParams.get("state") || undefined
92
+ };
93
+ } catch {}
94
+
95
+ if (value.includes("code=")) {
96
+ const params = new URLSearchParams(value);
97
+ return {
98
+ code: params.get("code") || undefined,
99
+ state: params.get("state") || undefined
100
+ };
101
+ }
102
+
103
+ if (value.includes("#")) {
104
+ const [code, state] = value.split("#", 2);
105
+ return { code: code || undefined, state: state || undefined };
106
+ }
107
+
108
+ return { code: value };
109
+ }
110
+
111
+ function normalizeTokenRecord(record = {}, existing = {}) {
112
+ const accessToken = record.access_token || existing.access_token;
113
+ const refreshToken = record.refresh_token || existing.refresh_token;
114
+ if (!accessToken) throw new Error("OpenAI token response missing access token");
115
+ if (!refreshToken) throw new Error("OpenAI token response missing refresh token");
116
+
117
+ const expiresAt = record.expires_at || (record.expires_in ? normalizeExpiresAt(record.expires_in) : existing.expires_at);
118
+ if (!expiresAt) throw new Error("OpenAI token response missing expiry");
119
+
120
+ const accountId = record.account_id || existing.account_id || extractOpenAIAccountId(accessToken);
121
+ if (!accountId) throw new Error("Failed to extract OpenAI account id");
122
+
123
+ return {
124
+ provider: "openai-codex",
125
+ client_id: OPENAI_CODEX_CLIENT_ID,
126
+ authorize_url: OPENAI_CODEX_AUTHORIZE_URL,
127
+ token_url: OPENAI_CODEX_TOKEN_URL,
128
+ redirect_uri: record.redirect_uri || existing.redirect_uri || OPENAI_CODEX_REDIRECT_URI,
129
+ scope: record.scope || existing.scope || OPENAI_CODEX_SCOPE,
130
+ originator: record.originator || existing.originator || "mercuria",
131
+ access_token: accessToken,
132
+ refresh_token: refreshToken,
133
+ expires_at: expiresAt,
134
+ account_id: accountId,
135
+ saved_at: new Date().toISOString()
136
+ };
137
+ }
138
+
139
+ export async function beginOpenAILogin({ profile = "operator", originator = "mercuria", redirectUri = OPENAI_CODEX_REDIRECT_URI } = {}) {
140
+ const flow = createAuthorizationFlow({ originator, redirectUri });
141
+ await writeOAuthPending(profile, {
142
+ schemaVersion: 1,
143
+ provider: "openai-codex",
144
+ profile,
145
+ originator,
146
+ verifier: flow.verifier,
147
+ state: flow.state,
148
+ redirect_uri: redirectUri,
149
+ authorize_url: OPENAI_CODEX_AUTHORIZE_URL,
150
+ token_url: OPENAI_CODEX_TOKEN_URL,
151
+ client_id: OPENAI_CODEX_CLIENT_ID,
152
+ scope: OPENAI_CODEX_SCOPE,
153
+ created_at: new Date().toISOString()
154
+ });
155
+
156
+ return {
157
+ profile,
158
+ authUrl: flow.url,
159
+ state: flow.state,
160
+ redirectUri
161
+ };
162
+ }
163
+
164
+ export async function exchangeAuthorizationCode({ code, verifier, redirectUri = OPENAI_CODEX_REDIRECT_URI, fetchImpl = fetch } = {}) {
165
+ const response = await fetchImpl(OPENAI_CODEX_TOKEN_URL, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/x-www-form-urlencoded" },
168
+ body: new URLSearchParams({
169
+ grant_type: "authorization_code",
170
+ client_id: OPENAI_CODEX_CLIENT_ID,
171
+ code,
172
+ code_verifier: verifier,
173
+ redirect_uri: redirectUri
174
+ })
175
+ });
176
+
177
+ if (!response.ok) {
178
+ throw new Error(`OpenAI code exchange failed: ${response.status} ${await response.text()}`);
179
+ }
180
+
181
+ return normalizeTokenRecord(await response.json(), { redirect_uri: redirectUri });
182
+ }
183
+
184
+ export async function finishOpenAILogin({ profile = "operator", redirectUrl, fetchImpl = fetch } = {}) {
185
+ const pending = await readOAuthPending(profile);
186
+ if (!pending) {
187
+ throw new Error(`No pending OpenAI OAuth flow for profile "${profile}"`);
188
+ }
189
+
190
+ const parsed = parseAuthorizationInput(redirectUrl);
191
+ if (!parsed.code) throw new Error("Missing authorization code in pasted redirect URL");
192
+ if (parsed.state && parsed.state !== pending.state) throw new Error("OpenAI OAuth state mismatch");
193
+
194
+ const record = await exchangeAuthorizationCode({
195
+ code: parsed.code,
196
+ verifier: pending.verifier,
197
+ redirectUri: pending.redirect_uri,
198
+ fetchImpl
199
+ });
200
+
201
+ await writeOAuthProfile(profile, {
202
+ ...record,
203
+ originator: pending.originator || record.originator
204
+ });
205
+ await deleteOAuthPending(profile);
206
+
207
+ return {
208
+ profile,
209
+ accountId: record.account_id,
210
+ expiresAt: record.expires_at
211
+ };
212
+ }
213
+
214
+ export async function refreshOpenAIProfile(profile = "operator", { fetchImpl = fetch } = {}) {
215
+ const stored = await readOAuthProfile(profile);
216
+ if (!stored?.refresh_token) {
217
+ throw new Error(`No refresh token stored for profile "${profile}"`);
218
+ }
219
+
220
+ const response = await fetchImpl(OPENAI_CODEX_TOKEN_URL, {
221
+ method: "POST",
222
+ headers: { "content-type": "application/x-www-form-urlencoded" },
223
+ body: new URLSearchParams({
224
+ grant_type: "refresh_token",
225
+ refresh_token: stored.refresh_token,
226
+ client_id: OPENAI_CODEX_CLIENT_ID
227
+ })
228
+ });
229
+
230
+ if (!response.ok) {
231
+ throw new Error(`OpenAI refresh failed: ${response.status} ${await response.text()}`);
232
+ }
233
+
234
+ const updated = normalizeTokenRecord(await response.json(), stored);
235
+ await writeOAuthProfile(profile, {
236
+ ...stored,
237
+ ...updated
238
+ });
239
+
240
+ return {
241
+ profile,
242
+ accountId: updated.account_id,
243
+ expiresAt: updated.expires_at
244
+ };
245
+ }
246
+
247
+ export async function readOpenAIProfile(profile = "operator") {
248
+ return readOAuthProfile(profile);
249
+ }
250
+