onbuzz 4.6.2 → 4.6.4
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/package.json
CHANGED
|
@@ -32,6 +32,7 @@ import { getCredentialVault } from '../services/credentialVault.js';
|
|
|
32
32
|
import { getGalleryService } from '../services/galleryService.js';
|
|
33
33
|
import { getUserDataPaths } from '../utilities/userDataDir.js';
|
|
34
34
|
import { projectActiveRuns } from '../utilities/flowRunFilters.js';
|
|
35
|
+
import { recordOAuthResult, recordResolvedUser, projectAuthStatus } from '../utilities/authCache.js';
|
|
35
36
|
|
|
36
37
|
// Connect visual editor server to bridge (enables element selection forwarding)
|
|
37
38
|
setBridgeGetter(getVisualEditorBridge);
|
|
@@ -863,12 +864,17 @@ class WebServer {
|
|
|
863
864
|
}
|
|
864
865
|
|
|
865
866
|
// Cache the auth result so the frontend can poll it if the WebSocket
|
|
866
|
-
// broadcast is missed (e.g. connection drop, race condition)
|
|
867
|
-
|
|
867
|
+
// broadcast is missed (e.g. connection drop, race condition).
|
|
868
|
+
// The JWT MUST be included so the polling fallback in
|
|
869
|
+
// `/api/auth/status` can hand it back to the FE — without it,
|
|
870
|
+
// marketplace publish/install/rate calls would 401 because the
|
|
871
|
+
// FE never got the token into localStorage. Logic lives in
|
|
872
|
+
// `utilities/authCache.js` so unit tests can lock the contract.
|
|
873
|
+
this.lastAuthResult = recordOAuthResult({
|
|
868
874
|
user: userInfo,
|
|
875
|
+
jwt: token,
|
|
869
876
|
hasApiKey: !!apiKey,
|
|
870
|
-
|
|
871
|
-
};
|
|
877
|
+
});
|
|
872
878
|
|
|
873
879
|
// Broadcast auth success to connected web UI clients
|
|
874
880
|
// Include the raw JWT so the frontend can use it for marketplace API calls
|
|
@@ -905,23 +911,13 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
905
911
|
// Supports ?since=<ISO> for polling: only returns user if auth happened AFTER that timestamp
|
|
906
912
|
this.app.get('/api/auth/status', (req, res) => {
|
|
907
913
|
const hasApiKey = !!(this.apiKeyManager?.getKeysForRequest?.(null, { platformProvided: true })?.loxiaApiKey);
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
user = null; // Auth happened before the login attempt — not the one we're waiting for
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
res.json({
|
|
920
|
-
success: true,
|
|
921
|
-
authenticated: hasApiKey,
|
|
922
|
-
user,
|
|
923
|
-
timestamp: this.lastAuthResult?.timestamp || null
|
|
924
|
-
});
|
|
914
|
+
// Projection (staleness gate + jwt/user pair invariant) lives in
|
|
915
|
+
// utilities/authCache.js so unit tests can exercise it without
|
|
916
|
+
// standing up Express.
|
|
917
|
+
res.json(projectAuthStatus(this.lastAuthResult, {
|
|
918
|
+
since: req.query.since,
|
|
919
|
+
hasApiKey,
|
|
920
|
+
}));
|
|
925
921
|
});
|
|
926
922
|
|
|
927
923
|
// Auth resolve — resolve user account from an existing API key by calling the remote backend
|
|
@@ -945,12 +941,14 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
945
941
|
const data = await response.json();
|
|
946
942
|
const user = data.user || data;
|
|
947
943
|
|
|
948
|
-
// Cache the resolved user for subsequent /api/auth/status calls
|
|
949
|
-
this
|
|
944
|
+
// Cache the resolved user for subsequent /api/auth/status calls.
|
|
945
|
+
// Preserves any JWT from a prior OAuth login (this path mints
|
|
946
|
+
// no JWT of its own — it just links a user from an existing
|
|
947
|
+
// API key). See utilities/authCache.js for the invariant.
|
|
948
|
+
this.lastAuthResult = recordResolvedUser({
|
|
950
949
|
user,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
};
|
|
950
|
+
prevCache: this.lastAuthResult,
|
|
951
|
+
});
|
|
954
952
|
|
|
955
953
|
res.json({ success: true, user });
|
|
956
954
|
} catch (error) {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the auth-cache shape + state transitions used by the
|
|
3
|
+
* webServer's /auth/callback, /api/auth/status, and /api/auth/resolve
|
|
4
|
+
* routes.
|
|
5
|
+
*
|
|
6
|
+
* These lock the marketplace-auth bug we fixed in 2026-05: a user who
|
|
7
|
+
* missed the OAuth WS broadcast (page reload, dropped connection)
|
|
8
|
+
* would recover their user account via the polling fallback but lose
|
|
9
|
+
* the JWT, leading to 401 "Authentication required" on every
|
|
10
|
+
* marketplace publish/install/rate call.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from '@jest/globals';
|
|
13
|
+
import {
|
|
14
|
+
recordOAuthResult,
|
|
15
|
+
recordResolvedUser,
|
|
16
|
+
projectAuthStatus,
|
|
17
|
+
} from '../authCache.js';
|
|
18
|
+
|
|
19
|
+
// ─── recordOAuthResult ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe('recordOAuthResult', () => {
|
|
22
|
+
test('captures user, jwt, hasApiKey + a timestamp on the cache row', () => {
|
|
23
|
+
const row = recordOAuthResult({
|
|
24
|
+
user: { id: 'u1', email: 'a@b.com' },
|
|
25
|
+
jwt: 'eyJhbG...real-jwt',
|
|
26
|
+
hasApiKey: true,
|
|
27
|
+
});
|
|
28
|
+
expect(row.user).toEqual({ id: 'u1', email: 'a@b.com' });
|
|
29
|
+
expect(row.jwt).toBe('eyJhbG...real-jwt');
|
|
30
|
+
expect(row.hasApiKey).toBe(true);
|
|
31
|
+
expect(typeof row.timestamp).toBe('string');
|
|
32
|
+
// Must be a valid ISO datetime
|
|
33
|
+
expect(new Date(row.timestamp).toString()).not.toBe('Invalid Date');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('CRITICAL: jwt is on the cache row — this is the contract that prevents 401-on-marketplace', () => {
|
|
37
|
+
// The bug we're guarding against: previously this function
|
|
38
|
+
// implicitly did `{user, hasApiKey, timestamp}` and forgot the JWT.
|
|
39
|
+
// Polling fallback then returned no JWT and marketplace auth broke.
|
|
40
|
+
const row = recordOAuthResult({ user: { id: 'u' }, jwt: 'tok', hasApiKey: true });
|
|
41
|
+
expect(row).toHaveProperty('jwt', 'tok');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('non-string jwt collapses to null (defensive against malformed input)', () => {
|
|
45
|
+
expect(recordOAuthResult({ user: {}, jwt: null, hasApiKey: false }).jwt).toBeNull();
|
|
46
|
+
expect(recordOAuthResult({ user: {}, jwt: undefined, hasApiKey: false }).jwt).toBeNull();
|
|
47
|
+
expect(recordOAuthResult({ user: {}, jwt: '', hasApiKey: false }).jwt).toBeNull();
|
|
48
|
+
expect(recordOAuthResult({ user: {}, jwt: 12345, hasApiKey: false }).jwt).toBeNull();
|
|
49
|
+
expect(recordOAuthResult({ user: {}, jwt: { token: 'x' }, hasApiKey: false }).jwt).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('hasApiKey coerces to bool', () => {
|
|
53
|
+
expect(recordOAuthResult({ hasApiKey: 1 }).hasApiKey).toBe(true);
|
|
54
|
+
expect(recordOAuthResult({ hasApiKey: 0 }).hasApiKey).toBe(false);
|
|
55
|
+
expect(recordOAuthResult({ hasApiKey: 'yes' }).hasApiKey).toBe(true);
|
|
56
|
+
expect(recordOAuthResult({}).hasApiKey).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('null user passes through as null (degraded but valid cache row)', () => {
|
|
60
|
+
expect(recordOAuthResult({ user: null, jwt: 'x', hasApiKey: true }).user).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('explicit timestamp wins (useful for deterministic tests)', () => {
|
|
64
|
+
const row = recordOAuthResult({ user: {}, timestamp: '2026-05-01T00:00:00Z' });
|
|
65
|
+
expect(row.timestamp).toBe('2026-05-01T00:00:00Z');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── recordResolvedUser ───────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('recordResolvedUser', () => {
|
|
72
|
+
test('CRITICAL: preserves a JWT from a previous OAuth cache row', () => {
|
|
73
|
+
// Resolve happens AFTER /auth/callback when the FE loads with an
|
|
74
|
+
// existing API key. Earlier code overwrote lastAuthResult and
|
|
75
|
+
// silently wiped the OAuth-minted JWT, breaking marketplace auth.
|
|
76
|
+
const prev = recordOAuthResult({ user: { id: 'u1' }, jwt: 'oauth-token', hasApiKey: true });
|
|
77
|
+
const next = recordResolvedUser({ user: { id: 'u1', email: 'a@b' }, prevCache: prev });
|
|
78
|
+
expect(next.jwt).toBe('oauth-token');
|
|
79
|
+
expect(next.user).toEqual({ id: 'u1', email: 'a@b' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('hasApiKey is always true on this path (resolve requires an API key)', () => {
|
|
83
|
+
expect(recordResolvedUser({ user: { id: 'u' }, prevCache: null }).hasApiKey).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('no prior JWT → cache row has jwt: null (no marketplace auth available)', () => {
|
|
87
|
+
const next = recordResolvedUser({ user: { id: 'u' }, prevCache: null });
|
|
88
|
+
expect(next.jwt).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('prior cache without jwt → null (matches the no-prior case)', () => {
|
|
92
|
+
const prev = { user: { id: 'u' }, hasApiKey: true, timestamp: '2026-01-01T00:00:00Z' };
|
|
93
|
+
const next = recordResolvedUser({ user: { id: 'u' }, prevCache: prev });
|
|
94
|
+
expect(next.jwt).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('prior cache with empty/non-string jwt is treated as no jwt', () => {
|
|
98
|
+
for (const badJwt of [null, undefined, '', 0, {}, []]) {
|
|
99
|
+
const prev = { user: {}, hasApiKey: true, jwt: badJwt, timestamp: '2026-01-01T00:00:00Z' };
|
|
100
|
+
expect(recordResolvedUser({ user: {}, prevCache: prev }).jwt).toBeNull();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('explicit timestamp wins (deterministic tests)', () => {
|
|
105
|
+
const next = recordResolvedUser({
|
|
106
|
+
user: { id: 'u' },
|
|
107
|
+
prevCache: null,
|
|
108
|
+
timestamp: '2026-05-03T12:00:00Z',
|
|
109
|
+
});
|
|
110
|
+
expect(next.timestamp).toBe('2026-05-03T12:00:00Z');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── projectAuthStatus ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe('projectAuthStatus', () => {
|
|
117
|
+
test('returns null user + null jwt when no cache exists yet', () => {
|
|
118
|
+
const r = projectAuthStatus(null);
|
|
119
|
+
expect(r.user).toBeNull();
|
|
120
|
+
expect(r.jwt).toBeNull();
|
|
121
|
+
expect(r.success).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('returns the cached user + jwt when fresh', () => {
|
|
125
|
+
const cache = recordOAuthResult({
|
|
126
|
+
user: { id: 'u1' }, jwt: 'tok', hasApiKey: true,
|
|
127
|
+
});
|
|
128
|
+
const r = projectAuthStatus(cache);
|
|
129
|
+
expect(r.user).toEqual({ id: 'u1' });
|
|
130
|
+
expect(r.jwt).toBe('tok');
|
|
131
|
+
expect(r.authenticated).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('CRITICAL: jwt round-trips from recordOAuthResult through projectAuthStatus', () => {
|
|
135
|
+
// The full bug-fix contract: callback → cache → status → FE can read JWT.
|
|
136
|
+
const cache = recordOAuthResult({
|
|
137
|
+
user: { id: 'u' }, jwt: 'the-jwt-value', hasApiKey: true,
|
|
138
|
+
});
|
|
139
|
+
const status = projectAuthStatus(cache);
|
|
140
|
+
expect(status.jwt).toBe('the-jwt-value');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('staleness gate: cache predating ?since= reports user=null (and jwt=null)', () => {
|
|
144
|
+
const cache = recordOAuthResult({
|
|
145
|
+
user: { id: 'u' }, jwt: 'old-tok', hasApiKey: true,
|
|
146
|
+
timestamp: '2026-05-01T10:00:00Z',
|
|
147
|
+
});
|
|
148
|
+
// Caller asking about an auth event AFTER the cache was written.
|
|
149
|
+
const r = projectAuthStatus(cache, { since: '2026-05-01T11:00:00Z' });
|
|
150
|
+
expect(r.user).toBeNull();
|
|
151
|
+
expect(r.jwt).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('staleness gate: cache POST-DATING ?since= passes through', () => {
|
|
155
|
+
const cache = recordOAuthResult({
|
|
156
|
+
user: { id: 'u' }, jwt: 'fresh-tok', hasApiKey: true,
|
|
157
|
+
timestamp: '2026-05-01T11:00:00Z',
|
|
158
|
+
});
|
|
159
|
+
const r = projectAuthStatus(cache, { since: '2026-05-01T10:00:00Z' });
|
|
160
|
+
expect(r.user).toEqual({ id: 'u' });
|
|
161
|
+
expect(r.jwt).toBe('fresh-tok');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('staleness gate: equal timestamps → stale (strict <=)', () => {
|
|
165
|
+
const cache = recordOAuthResult({
|
|
166
|
+
user: { id: 'u' }, jwt: 'tok', hasApiKey: true,
|
|
167
|
+
timestamp: '2026-05-01T10:00:00Z',
|
|
168
|
+
});
|
|
169
|
+
const r = projectAuthStatus(cache, { since: '2026-05-01T10:00:00Z' });
|
|
170
|
+
expect(r.user).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('PAIR INVARIANT: when user is null, jwt MUST be null too (never user=null with jwt=set)', () => {
|
|
174
|
+
// Cache exists but is stale → user=null. JWT must follow.
|
|
175
|
+
const cache = recordOAuthResult({
|
|
176
|
+
user: { id: 'u' }, jwt: 'tok',
|
|
177
|
+
timestamp: '2026-05-01T00:00:00Z',
|
|
178
|
+
});
|
|
179
|
+
const r = projectAuthStatus(cache, { since: '2026-05-02T00:00:00Z' });
|
|
180
|
+
expect(r.user).toBeNull();
|
|
181
|
+
expect(r.jwt).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('user without jwt (resolve-via-api-key path) returns user but jwt=null', () => {
|
|
185
|
+
// /auth/resolve sets user but no JWT. Marketplace will 401 — correct
|
|
186
|
+
// behavior, not this fix's job to mint a JWT here.
|
|
187
|
+
const cache = recordResolvedUser({ user: { id: 'u' }, prevCache: null });
|
|
188
|
+
const r = projectAuthStatus(cache);
|
|
189
|
+
expect(r.user).toEqual({ id: 'u' });
|
|
190
|
+
expect(r.jwt).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('authenticated reflects the live hasApiKey override (not the cache value)', () => {
|
|
194
|
+
// The route passes the apiKeyManager's CURRENT view; cache might be stale.
|
|
195
|
+
const cache = recordOAuthResult({ user: { id: 'u' }, jwt: 'x', hasApiKey: true });
|
|
196
|
+
const r = projectAuthStatus(cache, { hasApiKey: false });
|
|
197
|
+
expect(r.authenticated).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('falls back to cache.hasApiKey when no override given', () => {
|
|
201
|
+
const cache = recordOAuthResult({ user: { id: 'u' }, jwt: 'x', hasApiKey: true });
|
|
202
|
+
const r = projectAuthStatus(cache);
|
|
203
|
+
expect(r.authenticated).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ─── End-to-end scenario coverage (the actual bug story) ──────────────
|
|
208
|
+
|
|
209
|
+
describe('full scenario: OAuth → resolve → polling fallback (the bug we fixed)', () => {
|
|
210
|
+
test('happy path: OAuth callback → poll status → JWT returned', () => {
|
|
211
|
+
// 1. User signs in via OAuth. /auth/callback runs:
|
|
212
|
+
let cache = recordOAuthResult({
|
|
213
|
+
user: { id: 'u1', email: 'alice@example.com' },
|
|
214
|
+
jwt: 'oauth-issued-jwt',
|
|
215
|
+
hasApiKey: true,
|
|
216
|
+
});
|
|
217
|
+
// 2. WebSocket broadcast misses (page reload). FE polls /api/auth/status:
|
|
218
|
+
const status = projectAuthStatus(cache, { hasApiKey: true });
|
|
219
|
+
// 3. Polling response now contains jwt → FE writes to localStorage → marketplace auth works
|
|
220
|
+
expect(status.user.email).toBe('alice@example.com');
|
|
221
|
+
expect(status.jwt).toBe('oauth-issued-jwt');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('OAuth then resolve: JWT survives the resolve-overwrite', () => {
|
|
225
|
+
// 1. OAuth login caches everything including the JWT.
|
|
226
|
+
let cache = recordOAuthResult({
|
|
227
|
+
user: { id: 'u1' }, jwt: 'oauth-jwt', hasApiKey: true,
|
|
228
|
+
});
|
|
229
|
+
// 2. Frontend later hits /auth/resolve (e.g. on a fresh page load
|
|
230
|
+
// where API key is already set). Without the fix this would have
|
|
231
|
+
// overwritten cache.jwt = undefined.
|
|
232
|
+
cache = recordResolvedUser({
|
|
233
|
+
user: { id: 'u1', email: 'alice@example.com' },
|
|
234
|
+
prevCache: cache,
|
|
235
|
+
});
|
|
236
|
+
// 3. Subsequent /api/auth/status caller still gets the JWT.
|
|
237
|
+
const status = projectAuthStatus(cache);
|
|
238
|
+
expect(status.jwt).toBe('oauth-jwt');
|
|
239
|
+
expect(status.user.email).toBe('alice@example.com');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('API-key-only flow: user resolves but no marketplace auth available', () => {
|
|
243
|
+
// No prior OAuth — the user pasted an API key in Settings and the
|
|
244
|
+
// /auth/resolve path linked their account from the backend.
|
|
245
|
+
let cache = recordResolvedUser({ user: { id: 'u' }, prevCache: null });
|
|
246
|
+
const status = projectAuthStatus(cache);
|
|
247
|
+
expect(status.user).toEqual({ id: 'u' });
|
|
248
|
+
expect(status.jwt).toBeNull();
|
|
249
|
+
// Marketplace will 401 with "Authentication required" — the fix
|
|
250
|
+
// for that case is server-to-server JWT minting in /auth/resolve,
|
|
251
|
+
// out of scope for this PR.
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('polling for a NEW login but cache is from an OLDER one: not the auth we\'re waiting for', () => {
|
|
255
|
+
// A previous user signed out, a new user is mid-login, FE polls.
|
|
256
|
+
const oldCache = recordOAuthResult({
|
|
257
|
+
user: { id: 'old-user' }, jwt: 'old-jwt', hasApiKey: true,
|
|
258
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
259
|
+
});
|
|
260
|
+
// FE called /api/auth/status?since=<just-now>
|
|
261
|
+
const status = projectAuthStatus(oldCache, { since: '2026-05-03T12:00:00Z' });
|
|
262
|
+
// user=null + jwt=null so the FE keeps polling, doesn't sign in as the wrong user.
|
|
263
|
+
expect(status.user).toBeNull();
|
|
264
|
+
expect(status.jwt).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-result cache shape + transitions.
|
|
3
|
+
*
|
|
4
|
+
* The webServer caches the most recent auth event in `lastAuthResult` so
|
|
5
|
+
* a frontend that missed the WS broadcast (page reload, dropped
|
|
6
|
+
* connection, race) can recover via polling `/api/auth/status`. The
|
|
7
|
+
* cache shape and three state transitions live here so they're
|
|
8
|
+
* testable without standing up Express.
|
|
9
|
+
*
|
|
10
|
+
* Cache shape:
|
|
11
|
+
* { user, hasApiKey, jwt, timestamp }
|
|
12
|
+
*
|
|
13
|
+
* - user — { id, email, ... } or null
|
|
14
|
+
* - hasApiKey — bool
|
|
15
|
+
* - jwt — raw bearer token from OAuth, or null. Mandatory for
|
|
16
|
+
* marketplace publish/install/rate calls (the marketplace
|
|
17
|
+
* service verifies JWTs directly, not lx_* keys).
|
|
18
|
+
* - timestamp — ISO of when this cache row was written; used for
|
|
19
|
+
* staleness checks via `?since=` polling.
|
|
20
|
+
*
|
|
21
|
+
* Bug history this guards against:
|
|
22
|
+
* - 2026-05: marketplace publish 401 "Authentication required" because
|
|
23
|
+
* OAuth callback cached only {user, hasApiKey, timestamp} without
|
|
24
|
+
* the JWT, then a page reload triggered the polling fallback which
|
|
25
|
+
* returned the user but no JWT. The FE thought "I'm signed in" but
|
|
26
|
+
* localStorage.loxia-auth-token stayed empty → no Bearer header on
|
|
27
|
+
* marketplace calls → 401.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a cache row for a successful OAuth callback. The JWT is the
|
|
32
|
+
* critical field — without it, the polling fallback can't restore
|
|
33
|
+
* marketplace auth on a refresh.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} input
|
|
36
|
+
* @param {Object} input.user - User account object
|
|
37
|
+
* @param {string|null} input.jwt - Raw JWT from the OAuth callback
|
|
38
|
+
* @param {boolean} input.hasApiKey
|
|
39
|
+
* @param {string} [input.timestamp] - Override (for tests); defaults to now
|
|
40
|
+
* @returns {Object} cache row
|
|
41
|
+
*/
|
|
42
|
+
export function recordOAuthResult({ user, jwt, hasApiKey, timestamp } = {}) {
|
|
43
|
+
return {
|
|
44
|
+
user: user || null,
|
|
45
|
+
hasApiKey: !!hasApiKey,
|
|
46
|
+
jwt: typeof jwt === 'string' && jwt.length > 0 ? jwt : null,
|
|
47
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a cache row when a user is resolved from an API key alone (no
|
|
53
|
+
* OAuth event, so no JWT minted in this flow). PRESERVES any JWT from
|
|
54
|
+
* a previous OAuth login — overwriting `lastAuthResult` with no `jwt`
|
|
55
|
+
* field would silently wipe marketplace auth for users who hit
|
|
56
|
+
* /auth/resolve after /auth/callback.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} input
|
|
59
|
+
* @param {Object} input.user
|
|
60
|
+
* @param {Object|null} input.prevCache - previous lastAuthResult
|
|
61
|
+
* @param {string} [input.timestamp]
|
|
62
|
+
* @returns {Object} cache row
|
|
63
|
+
*/
|
|
64
|
+
export function recordResolvedUser({ user, prevCache, timestamp } = {}) {
|
|
65
|
+
const carriedJwt = prevCache && typeof prevCache.jwt === 'string' && prevCache.jwt.length > 0
|
|
66
|
+
? prevCache.jwt
|
|
67
|
+
: null;
|
|
68
|
+
return {
|
|
69
|
+
user: user || null,
|
|
70
|
+
hasApiKey: true, // resolve path requires an API key
|
|
71
|
+
jwt: carriedJwt,
|
|
72
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Project a cache row into the JSON the `/api/auth/status` endpoint
|
|
78
|
+
* returns. Implements two pieces of business logic:
|
|
79
|
+
*
|
|
80
|
+
* 1. Staleness gate via `?since=`: if the caller is polling for an
|
|
81
|
+
* auth event AFTER a known timestamp, but the cache row predates
|
|
82
|
+
* that timestamp, the row is considered "not the auth we're
|
|
83
|
+
* waiting for" and the user is reported as null. Prevents a
|
|
84
|
+
* previously-cached login from masquerading as the in-flight one.
|
|
85
|
+
*
|
|
86
|
+
* 2. JWT only flows out when a fresh user is reported. When `user`
|
|
87
|
+
* is null (no cached row, or stale), `jwt` is also null — the
|
|
88
|
+
* pair is always coherent so the FE never sees user=null with
|
|
89
|
+
* jwt=<something>.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object|null} cache - lastAuthResult value
|
|
92
|
+
* @param {Object} [options]
|
|
93
|
+
* @param {string} [options.since] - ISO timestamp from the caller
|
|
94
|
+
* @param {boolean} [options.hasApiKey] - hasApiKey value at request time
|
|
95
|
+
* (cache.hasApiKey is the value AT CACHE TIME — the live answer comes
|
|
96
|
+
* from the apiKeyManager). The route passes the live one in.
|
|
97
|
+
* @returns {{ success: true, authenticated: boolean, user: Object|null, jwt: string|null, timestamp: string|null }}
|
|
98
|
+
*/
|
|
99
|
+
export function projectAuthStatus(cache, options = {}) {
|
|
100
|
+
const since = options.since;
|
|
101
|
+
const liveHasApiKey = options.hasApiKey === undefined ? !!cache?.hasApiKey : !!options.hasApiKey;
|
|
102
|
+
|
|
103
|
+
let user = cache?.user || null;
|
|
104
|
+
if (since && cache?.timestamp) {
|
|
105
|
+
if (new Date(cache.timestamp).getTime() <= new Date(since).getTime()) {
|
|
106
|
+
// Cache predates the login attempt the caller is polling for.
|
|
107
|
+
user = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Pair invariant: jwt is null whenever user is null.
|
|
112
|
+
const jwt = user ? (cache?.jwt || null) : null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
authenticated: liveHasApiKey,
|
|
117
|
+
user,
|
|
118
|
+
jwt,
|
|
119
|
+
timestamp: cache?.timestamp || null,
|
|
120
|
+
};
|
|
121
|
+
}
|