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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onbuzz",
3
- "version": "4.6.2",
3
+ "version": "4.6.4",
4
4
  "description": "Loxia OnBuzz - Your AI Fleet",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
- this.lastAuthResult = {
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
- timestamp: new Date().toISOString()
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
- let user = this.lastAuthResult?.user || null;
910
-
911
- // Polling support: if ?since= is passed, only return user if auth happened after that time
912
- const since = req.query.since;
913
- if (since && this.lastAuthResult?.timestamp) {
914
- if (new Date(this.lastAuthResult.timestamp) <= new Date(since)) {
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.lastAuthResult = {
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
- hasApiKey: true,
952
- timestamp: new Date().toISOString()
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
+ }