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 +13 -0
- package/app/app.js +233 -17
- package/app/index.html +24 -0
- package/app/styles.css +82 -0
- package/netlify.toml +6 -0
- package/package.json +3 -2
- package/src/harness.mjs +166 -4
- package/src/index.mjs +7 -0
- package/src/shared/oauth-store.mjs +78 -0
- package/src/shared/openai-auth.mjs +250 -0
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercuria",
|
|
3
|
-
"version": "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, {
|
|
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
|
+
|