@cellarnode/auth 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,8 @@
1
+ import { copyAuthUser, parseAuthUser } from "./auth-user.js";
1
2
  import { extractAccessToken } from "./extract-token.js";
2
- /**
3
- * Map a `/auth/me` AuthUser onto the minimal cached identity.
4
- *
5
- * `AuthUser.id` is the userId. `entitlements` is optional on the wire (older
6
- * backends omit it) → defaults to `[]`. The element filter guards against a
7
- * malformed array carrying non-string entries.
8
- */
9
- function toIdentity(user) {
10
- const entitlements = Array.isArray(user.entitlements)
11
- ? user.entitlements.filter((e) => typeof e === "string")
12
- : [];
13
- return {
14
- userId: user.id,
15
- orgId: user.orgId ?? null,
16
- userType: user.userType,
17
- entitlements,
18
- };
19
- }
20
- /**
21
- * Fallback access-token lifetime, in seconds, when the server omits
22
- * `expiresIn`. Mirrors the verify-otp adoption path in `auth-api.ts`.
23
- */
3
+ import { fetchAuthRequest } from "./auth-transport.js";
24
4
  const DEFAULT_ACCESS_TOKEN_TTL = 900;
25
- /**
26
- * Developer-facing copy for each `devLogin()` failure (CEL-1364).
27
- *
28
- * The 404 message frames the outcome as "the backend gate is off" and nothing
29
- * else. The backend deliberately returns an identical 404 for "gate off" and
30
- * "no such user" (T3-1), so any copy that named the account would be both a
31
- * guess and a weakening of that contract.
32
- *
33
- * These strings SHIP in production bundles — they are referenced from
34
- * `devLogin`'s live body, which no bundler can prove unreachable. The
35
- * `ENABLE_TEST_ENDPOINTS` mention is therefore public, which is fine: the flag
36
- * is documented in this package's README and in the backend repo, and knowing
37
- * its name grants nothing when the route is not mounted. Moving the copy behind
38
- * the DEV-only React module would eliminate it, but only by taking the
39
- * ready-to-render `message` off `DevLoginFailure` — a public-API change, not a
40
- * review fixup.
41
- */
5
+ const DEFAULT_RESOLUTION_TIMEOUT_MS = 10_000;
42
6
  const DEV_LOGIN_MESSAGES = {
43
7
  "test-endpoints-disabled": "Dev sign-in unavailable: backend test endpoints are disabled. Set ENABLE_TEST_ENDPOINTS=true on the API and restart it.",
44
8
  "rate-limited": "Dev sign-in rate limit hit (5/min). Wait a minute and retry.",
@@ -46,239 +10,681 @@ const DEV_LOGIN_MESSAGES = {
46
10
  network: "Dev sign-in could not reach the API. Is the backend running?",
47
11
  "malformed-response": "Dev sign-in succeeded but the API returned no access token.",
48
12
  };
13
+ function copySessionState(state) {
14
+ return state.status === "ready"
15
+ ? { ...state, user: copyAuthUser(state.user) }
16
+ : { ...state };
17
+ }
18
+ function copyResolution(resolution) {
19
+ return resolution.status === "ready"
20
+ ? { ...resolution, user: copyAuthUser(resolution.user) }
21
+ : { ...resolution };
22
+ }
49
23
  export function createAuthStore(config) {
50
- const { baseUrl, refreshPath = "/auth/refresh", refreshBuffer = 60 } = config;
24
+ const { baseUrl, refreshPath = "/auth/refresh", revalidatePath = "/auth/revalidate", refreshBuffer = 60, resolutionTimeoutMs = DEFAULT_RESOLUTION_TIMEOUT_MS, } = config;
25
+ const requestTimeoutMs = Number.isFinite(resolutionTimeoutMs) && resolutionTimeoutMs > 0
26
+ ? resolutionTimeoutMs
27
+ : DEFAULT_RESOLUTION_TIMEOUT_MS;
51
28
  let accessToken = null;
52
29
  let identity = null;
53
30
  let refreshTimer = null;
54
- let refreshPromise = null;
55
- // Tracks the orgId observed at the last emitted state so `onOrgChange` only
56
- // fires on an actual transition. `hasEmittedOrgId` disambiguates the two
57
- // meanings `previousOrgId === null` would otherwise carry — "no org emitted
58
- // yet" vs "the current org is admin/null". Without it, the FIRST admin
59
- // identity (orgId null) and an admin login right after `clearToken()` would
60
- // skip `onOrgChange(null)`. Both are reset on logout so a subsequent login
61
- // re-fires `onOrgChange` even into the same org.
31
+ let identityFlight = null;
32
+ let refreshFlight = null;
33
+ let authorityFlight = null;
34
+ let explicitAdoptionFlight = null;
35
+ let tokenGeneration = 0;
62
36
  let previousOrgId = null;
63
37
  let hasEmittedOrgId = false;
64
- // Monotonic generation counter. Bumped on every token change (set / clear /
65
- // refresh). An in-flight `/auth/me` resolution only commits if its captured
66
- // generation still matches — this discards stale identity results when the
67
- // token changed again before the previous fetch resolved.
68
- let tokenGeneration = 0;
38
+ // Last validated principal/tenant survives transient authority outages. It
39
+ // never powers authority getters; it only prevents raw same-token identity
40
+ // changes from becoming trusted after an unavailable read.
41
+ let continuityBaseline = null;
42
+ // A refreshed credential may be adopted before its identity read succeeds.
43
+ // Preserve its old-principal lineage so a later retry still verifies account
44
+ // continuity while permitting a fresh-token organisation transition.
45
+ let pendingRefreshBaseline = null;
46
+ let sessionState = { status: "unauthorized" };
69
47
  const orgChangeListeners = new Set();
70
48
  const accessTokenSetListeners = new Set();
71
49
  const logoutListeners = new Set();
72
- function emitOrgChange(orgId) {
50
+ const sessionStateListeners = new Set();
51
+ const stateQueue = [];
52
+ let publishingState = false;
53
+ function settleExplicitGeneration(generation, resolution) {
54
+ if (explicitAdoptionFlight?.generation === generation) {
55
+ explicitAdoptionFlight.settle(resolution);
56
+ }
57
+ }
58
+ function emitOrgChange(orgId, shouldContinue = () => true) {
73
59
  for (const listener of orgChangeListeners) {
74
60
  try {
75
61
  listener(orgId);
76
62
  }
77
63
  catch {
78
- // Swallow listener errors so a single bad subscriber cannot
79
- // break event fan-out to other subscribers.
64
+ // Subscriber failures cannot break other observers or session adoption.
80
65
  }
66
+ if (!shouldContinue())
67
+ break;
81
68
  }
82
69
  }
83
- function emitAccessTokenSet(token) {
70
+ function emitAccessTokenSet(token, shouldContinue = () => true) {
84
71
  for (const listener of accessTokenSetListeners) {
85
72
  try {
86
73
  listener(token);
87
74
  }
88
75
  catch {
89
- // Swallow.
76
+ // Subscriber failures cannot break other observers or session adoption.
90
77
  }
78
+ if (!shouldContinue())
79
+ break;
91
80
  }
92
81
  }
93
- function emitLogout() {
82
+ function emitLogout(shouldContinue = () => true) {
94
83
  for (const listener of logoutListeners) {
95
84
  try {
96
85
  listener();
97
86
  }
98
87
  catch {
99
- // Swallow.
88
+ // Subscriber failures cannot break other observers or logout.
89
+ }
90
+ if (!shouldContinue())
91
+ break;
92
+ }
93
+ }
94
+ function publishSessionState(next) {
95
+ stateQueue.push(copySessionState(next));
96
+ if (publishingState)
97
+ return;
98
+ publishingState = true;
99
+ try {
100
+ while (stateQueue.length > 0) {
101
+ const published = stateQueue.shift();
102
+ if (!published)
103
+ continue;
104
+ sessionState = published;
105
+ for (const listener of sessionStateListeners) {
106
+ const queuedBeforeCallback = stateQueue.length;
107
+ try {
108
+ listener(copySessionState(published));
109
+ }
110
+ catch {
111
+ // Observers cannot veto or interrupt global session resolution.
112
+ }
113
+ if (stateQueue.length > queuedBeforeCallback)
114
+ break;
115
+ }
100
116
  }
101
117
  }
118
+ finally {
119
+ publishingState = false;
120
+ }
121
+ }
122
+ /** Notify consumers before authority or credentials can change. */
123
+ function beginResolving(generation, token) {
124
+ publishSessionState({ status: "resolving", token });
125
+ if (generation !== tokenGeneration)
126
+ return false;
127
+ identity = null;
128
+ return true;
102
129
  }
103
130
  function scheduleRefresh(expiresInSeconds) {
104
131
  if (refreshTimer)
105
132
  clearTimeout(refreshTimer);
106
133
  const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
107
134
  refreshTimer = setTimeout(() => {
108
- performRefresh();
135
+ void store.resolveSession({ refresh: true });
109
136
  }, delay);
110
137
  }
111
- /**
112
- * Fetch `/auth/me` with the bearer token and return the parsed identity, or
113
- * null on any failure (network error, non-2xx, malformed body).
114
- *
115
- * Tolerant by design — mirrors how `performRefresh` swallows errors. A failed
116
- * `/auth/me` must NOT throw out of `setAccessToken` / `performRefresh`; the
117
- * caller treats null identity as "logged in but identity unknown" and the
118
- * getters fall back to null/[].
119
- */
138
+ function withResolutionTimeout(operation) {
139
+ const controller = new AbortController();
140
+ return new Promise((resolve, reject) => {
141
+ const timer = setTimeout(() => {
142
+ controller.abort();
143
+ reject(new Error("Session resolution request timed out"));
144
+ }, requestTimeoutMs);
145
+ let pending;
146
+ try {
147
+ pending = operation(controller.signal);
148
+ }
149
+ catch (error) {
150
+ clearTimeout(timer);
151
+ reject(error);
152
+ return;
153
+ }
154
+ void pending.then((value) => {
155
+ clearTimeout(timer);
156
+ resolve(value);
157
+ }, (error) => {
158
+ clearTimeout(timer);
159
+ reject(error);
160
+ });
161
+ });
162
+ }
163
+ function fetchResolutionResponse(path, init) {
164
+ return withResolutionTimeout(async (signal) => {
165
+ const response = await fetchAuthRequest(baseUrl, path, {
166
+ ...init,
167
+ signal,
168
+ });
169
+ const raw = response.ok ? await response.json() : null;
170
+ return { response, raw };
171
+ });
172
+ }
120
173
  async function fetchIdentity(token) {
174
+ let result;
121
175
  try {
122
- const res = await fetch(`${baseUrl}/auth/me`, {
176
+ result = await fetchResolutionResponse("/auth/me", {
123
177
  method: "GET",
124
178
  credentials: "include",
125
179
  headers: { Authorization: `Bearer ${token}` },
126
180
  });
127
- if (!res.ok)
128
- return null;
129
- const user = (await res.json());
130
- if (!user || typeof user.id !== "string" || user.id.length === 0) {
131
- return null;
132
- }
133
- return toIdentity(user);
134
181
  }
135
182
  catch {
136
- return null;
183
+ return { status: "unavailable" };
137
184
  }
185
+ const { response, raw } = result;
186
+ if (response.status === 401 || response.status === 403) {
187
+ return { status: "unauthorized" };
188
+ }
189
+ if (!response.ok)
190
+ return { status: "unavailable" };
191
+ const user = parseAuthUser(raw);
192
+ return user
193
+ ? { status: "ready", user }
194
+ : { status: "unavailable" };
138
195
  }
139
- /**
140
- * Commit a freshly-resolved identity and emit the dependent events.
141
- *
142
- * EVENT ORDERING (CRITICAL — consumer realtime hooks depend on it):
143
- * identity is async, so on a token change the sequence is:
144
- * 1. set token (synchronous, in `setAccessToken` / `performRefresh`)
145
- * 2. kick off `fetchIdentity(token)` (async)
146
- * 3. ON RESOLVE → here:
147
- * a. cache identity FIRST (so getters return real values)
148
- * b. emit `onAccessTokenSet(token)` (consumer hooks re-read getOrgId()
149
- * inside this handler — identity must already be cached)
150
- * c. emit `onOrgChange(orgId)` IF orgId actually changed vs the last
151
- * emitted state
152
- *
153
- * The `generation` guard discards this commit if the token changed again
154
- * (set/clear/refresh) while this `/auth/me` was in flight.
155
- */
156
- function commitIdentity(generation, token, nextIdentity) {
157
- if (generation !== tokenGeneration) {
158
- // A newer token change superseded this fetch — drop the stale result.
159
- return;
196
+ function clearCurrentGeneration(generation) {
197
+ if (generation !== tokenGeneration)
198
+ return false;
199
+ tokenGeneration += 1;
200
+ const clearedGeneration = tokenGeneration;
201
+ accessToken = null;
202
+ identity = null;
203
+ continuityBaseline = null;
204
+ pendingRefreshBaseline = null;
205
+ identityFlight = null;
206
+ refreshFlight = null;
207
+ authorityFlight = null;
208
+ previousOrgId = null;
209
+ hasEmittedOrgId = false;
210
+ if (refreshTimer) {
211
+ clearTimeout(refreshTimer);
212
+ refreshTimer = null;
213
+ }
214
+ publishSessionState({ status: "unauthorized" });
215
+ if (tokenGeneration !== clearedGeneration || accessToken !== null)
216
+ return false;
217
+ emitAccessTokenSet(null, () => tokenGeneration === clearedGeneration && accessToken === null);
218
+ const cleared = tokenGeneration === clearedGeneration && accessToken === null;
219
+ if (cleared) {
220
+ settleExplicitGeneration(generation, { status: "unauthorized" });
221
+ }
222
+ return cleared;
223
+ }
224
+ function markUnavailable(generation, token) {
225
+ if (generation !== tokenGeneration || accessToken !== token) {
226
+ return { status: "superseded" };
227
+ }
228
+ identity = null;
229
+ publishSessionState({ status: "unavailable", token });
230
+ if (generation !== tokenGeneration || accessToken !== token) {
231
+ return { status: "superseded" };
232
+ }
233
+ const result = { status: "unavailable", token };
234
+ settleExplicitGeneration(generation, result);
235
+ return result;
236
+ }
237
+ function commitReady(generation, token, user) {
238
+ if (generation !== tokenGeneration || accessToken !== token) {
239
+ return { status: "superseded" };
240
+ }
241
+ identity = copyAuthUser(user);
242
+ const nextOrgId = identity.orgId;
243
+ continuityBaseline = { token, user: copyAuthUser(identity) };
244
+ pendingRefreshBaseline = null;
245
+ publishSessionState({ status: "ready", token, user: identity });
246
+ if (generation !== tokenGeneration || accessToken !== token) {
247
+ return { status: "superseded" };
248
+ }
249
+ emitAccessTokenSet(token, () => generation === tokenGeneration && accessToken === token);
250
+ if (generation !== tokenGeneration || accessToken !== token) {
251
+ return { status: "superseded" };
160
252
  }
161
- identity = nextIdentity;
162
- const nextOrgId = nextIdentity?.orgId ?? null;
163
- // (b) Token-set first — handlers read getOrgId()/getUserId() and must see
164
- // the just-cached identity.
165
- emitAccessTokenSet(token);
166
- // (c) Org-change on the first emission OR an actual transition. The
167
- // `!hasEmittedOrgId` guard ensures the first identity (incl. an admin
168
- // orgId of null) always fires once.
169
253
  if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
170
254
  hasEmittedOrgId = true;
171
255
  previousOrgId = nextOrgId;
172
- emitOrgChange(nextOrgId);
256
+ emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
257
+ }
258
+ if (generation !== tokenGeneration || accessToken !== token) {
259
+ return { status: "superseded" };
173
260
  }
261
+ const result = {
262
+ status: "ready",
263
+ token,
264
+ user: copyAuthUser(identity),
265
+ };
266
+ settleExplicitGeneration(generation, result);
267
+ return result;
174
268
  }
175
- /**
176
- * Apply a new access token, kicking off async identity resolution.
177
- *
178
- * The token is committed synchronously (so `getAccessToken()` is immediately
179
- * correct); identity + `onAccessTokenSet` + `onOrgChange` settle only after
180
- * `/auth/me` resolves (see `commitIdentity`).
181
- */
182
- function applyToken(token) {
269
+ async function evaluateIdentity(generation, token, baseline, refreshed) {
270
+ const read = await fetchIdentity(token);
271
+ if (generation !== tokenGeneration || accessToken !== token) {
272
+ return { status: "superseded" };
273
+ }
274
+ if (read.status === "unauthorized") {
275
+ return clearCurrentGeneration(generation)
276
+ ? { status: "unauthorized" }
277
+ : { status: "superseded" };
278
+ }
279
+ if (read.status === "unavailable") {
280
+ return markUnavailable(generation, token);
281
+ }
282
+ if (baseline && read.user.id !== baseline.user.id) {
283
+ if (!refreshed)
284
+ return runRefresh(generation, baseline);
285
+ return clearCurrentGeneration(generation)
286
+ ? { status: "unauthorized" }
287
+ : { status: "superseded" };
288
+ }
289
+ if (baseline && read.user.orgId !== baseline.user.orgId) {
290
+ if (!refreshed) {
291
+ // Same-token raw membership changes are not authority. Rotate once,
292
+ // then validate fresh credentials before adopting the transition.
293
+ return runRefresh(generation, baseline);
294
+ }
295
+ if (token === baseline.token) {
296
+ return markUnavailable(generation, token);
297
+ }
298
+ }
299
+ return commitReady(generation, token, read.user);
300
+ }
301
+ function startIdentityFlight(generation, token, baseline, refreshed, notifyResolving) {
302
+ if (identityFlight?.generation === generation) {
303
+ return identityFlight.promise;
304
+ }
305
+ let settle;
306
+ const promise = new Promise((resolve) => {
307
+ settle = resolve;
308
+ });
309
+ const flight = { generation, promise };
310
+ identityFlight = flight;
311
+ if (notifyResolving && !beginResolving(generation, token)) {
312
+ if (identityFlight === flight)
313
+ identityFlight = null;
314
+ settle({ status: "superseded" });
315
+ return promise;
316
+ }
317
+ void evaluateIdentity(generation, token, baseline, refreshed).then((result) => {
318
+ if (identityFlight === flight)
319
+ identityFlight = null;
320
+ settle(result);
321
+ }, () => {
322
+ if (identityFlight === flight)
323
+ identityFlight = null;
324
+ settle(markUnavailable(generation, token));
325
+ });
326
+ return promise;
327
+ }
328
+ function currentBaseline() {
329
+ const baseline = pendingRefreshBaseline ?? continuityBaseline;
330
+ return baseline
331
+ ? { token: baseline.token, user: copyAuthUser(baseline.user) }
332
+ : null;
333
+ }
334
+ function runRefresh(generation, baseline) {
335
+ if (refreshFlight)
336
+ return refreshFlight.promise;
337
+ if (generation !== tokenGeneration) {
338
+ return Promise.resolve({ status: "superseded" });
339
+ }
340
+ // Expiry renewal / forced refresh wins over an in-flight authority remint.
341
+ authorityFlight = null;
342
+ if (!baseline && identityFlight?.generation === generation) {
343
+ const pendingIdentity = identityFlight.promise;
344
+ return pendingIdentity.then((result) => {
345
+ if (result.status === "ready") {
346
+ return runRefresh(tokenGeneration, {
347
+ token: result.token,
348
+ user: copyAuthUser(result.user),
349
+ });
350
+ }
351
+ if (result.status === "unavailable" &&
352
+ generation === tokenGeneration &&
353
+ accessToken !== null) {
354
+ return runRefresh(tokenGeneration, null);
355
+ }
356
+ return result;
357
+ });
358
+ }
359
+ let settle;
360
+ const promise = new Promise((resolve) => {
361
+ settle = resolve;
362
+ });
363
+ const flight = { generation, promise };
364
+ refreshFlight = flight;
365
+ if (!beginResolving(generation, accessToken)) {
366
+ if (refreshFlight === flight)
367
+ refreshFlight = null;
368
+ settle({ status: "superseded" });
369
+ return promise;
370
+ }
371
+ // Refresh owns a new operation generation before transport begins. Any
372
+ // older identity read can no longer publish authority while refresh waits.
183
373
  tokenGeneration += 1;
184
- const generation = tokenGeneration;
185
- accessToken = token;
186
- // Clear the prior session's identity synchronously so the getters return
187
- // the documented null/[] defaults until the fresh /auth/me resolves —
188
- // otherwise a token switch (e.g. logging in as a different user) would leak
189
- // the previous session's userId/orgId/entitlements until the new fetch
190
- // lands. The realtime consumer hooks read the getters on events (not by
191
- // polling), so this introduces no flicker: a same-user refresh re-commits
192
- // the same orgId without firing `onOrgChange`.
193
- identity = null;
194
- void fetchIdentity(token).then((next) => commitIdentity(generation, token, next));
374
+ const refreshGeneration = tokenGeneration;
375
+ flight.generation = refreshGeneration;
376
+ void (async () => {
377
+ let result;
378
+ try {
379
+ result = await fetchResolutionResponse(refreshPath, {
380
+ method: "POST",
381
+ credentials: "include",
382
+ headers: { "Content-Type": "application/json" },
383
+ });
384
+ }
385
+ catch {
386
+ return markUnavailable(refreshGeneration, accessToken);
387
+ }
388
+ const { response, raw } = result;
389
+ if (refreshGeneration !== tokenGeneration)
390
+ return { status: "superseded" };
391
+ if (response.status === 401 || response.status === 403) {
392
+ return clearCurrentGeneration(refreshGeneration)
393
+ ? { status: "unauthorized" }
394
+ : { status: "superseded" };
395
+ }
396
+ if (!response.ok)
397
+ return markUnavailable(refreshGeneration, accessToken);
398
+ if (refreshGeneration !== tokenGeneration)
399
+ return { status: "superseded" };
400
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
401
+ return markUnavailable(refreshGeneration, accessToken);
402
+ }
403
+ const json = raw;
404
+ const nextToken = extractAccessToken(json);
405
+ if (!nextToken)
406
+ return markUnavailable(refreshGeneration, accessToken);
407
+ const expiresIn = typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn)
408
+ ? json.expiresIn
409
+ : DEFAULT_ACCESS_TOKEN_TTL;
410
+ tokenGeneration += 1;
411
+ const nextGeneration = tokenGeneration;
412
+ // Manual/timer callers arriving while refreshed identity still resolves
413
+ // join this same end-to-end refresh operation.
414
+ flight.generation = nextGeneration;
415
+ accessToken = nextToken;
416
+ identity = null;
417
+ pendingRefreshBaseline = baseline
418
+ ? { token: baseline.token, user: copyAuthUser(baseline.user) }
419
+ : null;
420
+ publishSessionState({ status: "resolving", token: nextToken });
421
+ if (nextGeneration !== tokenGeneration ||
422
+ accessToken !== nextToken) {
423
+ return { status: "superseded" };
424
+ }
425
+ scheduleRefresh(expiresIn);
426
+ return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
427
+ })().then((result) => {
428
+ if (refreshFlight === flight)
429
+ refreshFlight = null;
430
+ settle(result);
431
+ }, () => {
432
+ if (refreshFlight === flight)
433
+ refreshFlight = null;
434
+ settle(markUnavailable(refreshGeneration, accessToken));
435
+ });
436
+ return promise;
437
+ }
438
+ function readErrorCode(raw) {
439
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
440
+ return undefined;
441
+ const code = raw.code;
442
+ return typeof code === "string" ? code : undefined;
195
443
  }
196
444
  /**
197
- * Clear all token + identity state synchronously and reset org tracking.
198
- *
199
- * `onAccessTokenSet(null)` fires synchronously (no `/auth/me` round-trip on
200
- * logout). `previousOrgId` is reset so a subsequent login re-fires
201
- * `onOrgChange` even when the user logs back into the same org.
445
+ * Non-rotating authority remint (CEL-1853). Preserves the absolute refresh
446
+ * deadline/timer — never calls scheduleRefresh with a fresh TTL.
202
447
  */
203
- function clearToken() {
204
- tokenGeneration += 1; // invalidate any in-flight /auth/me
205
- accessToken = null;
206
- identity = null;
207
- previousOrgId = null;
208
- hasEmittedOrgId = false;
209
- emitAccessTokenSet(null);
210
- }
211
- async function performRefresh() {
212
- try {
213
- const res = await fetch(`${baseUrl}${refreshPath}`, {
214
- method: "POST",
215
- credentials: "include",
216
- headers: { "Content-Type": "application/json" },
217
- });
218
- if (!res.ok) {
219
- clearToken();
220
- return null;
448
+ function runRevalidate(generation) {
449
+ if (authorityFlight)
450
+ return authorityFlight.promise;
451
+ if (refreshFlight)
452
+ return refreshFlight.promise;
453
+ if (explicitAdoptionFlight)
454
+ return explicitAdoptionFlight.promise;
455
+ if (generation !== tokenGeneration) {
456
+ return Promise.resolve({ status: "superseded" });
457
+ }
458
+ const bearerToken = accessToken;
459
+ if (!bearerToken)
460
+ return Promise.resolve({ status: "unauthorized" });
461
+ let settle;
462
+ const promise = new Promise((resolve) => {
463
+ settle = resolve;
464
+ });
465
+ const flight = { generation, promise };
466
+ authorityFlight = flight;
467
+ if (!beginResolving(generation, bearerToken)) {
468
+ if (authorityFlight === flight)
469
+ authorityFlight = null;
470
+ settle({ status: "superseded" });
471
+ return promise;
472
+ }
473
+ // Reserve a generation before POST so earlier /me reads cannot publish.
474
+ tokenGeneration += 1;
475
+ const opGeneration = tokenGeneration;
476
+ flight.generation = opGeneration;
477
+ const baseline = currentBaseline();
478
+ void (async () => {
479
+ let result;
480
+ try {
481
+ result = await withResolutionTimeout(async (signal) => {
482
+ const response = await fetchAuthRequest(baseUrl, revalidatePath, {
483
+ method: "POST",
484
+ credentials: "omit",
485
+ headers: {
486
+ Authorization: `Bearer ${bearerToken}`,
487
+ "Content-Type": "application/json",
488
+ },
489
+ signal,
490
+ });
491
+ let raw = null;
492
+ try {
493
+ raw = await response.json();
494
+ }
495
+ catch {
496
+ raw = null;
497
+ }
498
+ return { response, raw };
499
+ });
221
500
  }
222
- const json = (await res.json());
223
- const token = extractAccessToken(json);
224
- if (token) {
225
- const expiresIn = typeof json.expiresIn === "number" ? json.expiresIn : 900;
226
- applyToken(token);
227
- scheduleRefresh(expiresIn);
501
+ catch {
502
+ return markUnavailable(opGeneration, accessToken);
228
503
  }
229
- return token;
504
+ if (opGeneration !== tokenGeneration)
505
+ return { status: "superseded" };
506
+ const { response, raw } = result;
507
+ const errorCode = readErrorCode(raw);
508
+ if (response.status === 401 || response.status === 403) {
509
+ // Exactly one expiry-classified fallback to ordinary refresh.
510
+ if (errorCode === "ACCESS_TOKEN_EXPIRED") {
511
+ if (authorityFlight === flight)
512
+ authorityFlight = null;
513
+ return runRefresh(tokenGeneration, baseline);
514
+ }
515
+ if (opGeneration !== tokenGeneration || accessToken !== bearerToken) {
516
+ return { status: "superseded" };
517
+ }
518
+ return clearCurrentGeneration(opGeneration)
519
+ ? { status: "unauthorized" }
520
+ : { status: "superseded" };
521
+ }
522
+ if (!response.ok)
523
+ return markUnavailable(opGeneration, accessToken);
524
+ if (opGeneration !== tokenGeneration)
525
+ return { status: "superseded" };
526
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
527
+ return markUnavailable(opGeneration, accessToken);
528
+ }
529
+ const nextToken = extractAccessToken(raw);
530
+ if (!nextToken)
531
+ return markUnavailable(opGeneration, accessToken);
532
+ tokenGeneration += 1;
533
+ const nextGeneration = tokenGeneration;
534
+ flight.generation = nextGeneration;
535
+ accessToken = nextToken;
536
+ identity = null;
537
+ // Reminted credential: retain prior baseline for continuity, allow org
538
+ // transition after /me (refreshed=true). Do NOT reschedule refresh.
539
+ pendingRefreshBaseline = baseline
540
+ ? { token: baseline.token, user: copyAuthUser(baseline.user) }
541
+ : null;
542
+ publishSessionState({ status: "resolving", token: nextToken });
543
+ if (nextGeneration !== tokenGeneration || accessToken !== nextToken) {
544
+ return { status: "superseded" };
545
+ }
546
+ return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
547
+ })().then((result) => {
548
+ if (authorityFlight === flight)
549
+ authorityFlight = null;
550
+ settle(result);
551
+ }, () => {
552
+ if (authorityFlight === flight)
553
+ authorityFlight = null;
554
+ settle(markUnavailable(opGeneration, accessToken));
555
+ });
556
+ return promise;
557
+ }
558
+ function resolveCurrentSession() {
559
+ const token = accessToken;
560
+ const generation = tokenGeneration;
561
+ if (!token)
562
+ return Promise.resolve({ status: "unauthorized" });
563
+ if (authorityFlight)
564
+ return authorityFlight.promise;
565
+ if (identityFlight?.generation === generation) {
566
+ return identityFlight.promise;
230
567
  }
231
- catch {
232
- clearToken();
233
- return null;
568
+ const baseline = currentBaseline();
569
+ const refreshed = pendingRefreshBaseline !== null;
570
+ return startIdentityFlight(generation, token, baseline, refreshed, true);
571
+ }
572
+ function waitForCaller(promise, signal) {
573
+ if (!signal)
574
+ return promise.then(copyResolution);
575
+ if (signal.aborted)
576
+ return Promise.resolve({ status: "superseded" });
577
+ return new Promise((resolve) => {
578
+ const onAbort = () => resolve({ status: "superseded" });
579
+ signal.addEventListener("abort", onAbort, { once: true });
580
+ void promise.then((result) => {
581
+ signal.removeEventListener("abort", onAbort);
582
+ resolve(copyResolution(result));
583
+ });
584
+ });
585
+ }
586
+ function supersedeExplicitAdoption() {
587
+ const flight = explicitAdoptionFlight;
588
+ if (!flight)
589
+ return;
590
+ explicitAdoptionFlight = null;
591
+ flight.settle({ status: "superseded" });
592
+ }
593
+ function startExplicitToken(token, expiresIn) {
594
+ tokenGeneration += 1;
595
+ const generation = tokenGeneration;
596
+ supersedeExplicitAdoption();
597
+ refreshFlight = null;
598
+ authorityFlight = null;
599
+ let settled = false;
600
+ let resolveFlight;
601
+ const promise = new Promise((resolve) => {
602
+ resolveFlight = resolve;
603
+ });
604
+ const flight = {
605
+ generation,
606
+ promise,
607
+ settle(resolution) {
608
+ if (settled)
609
+ return;
610
+ settled = true;
611
+ if (explicitAdoptionFlight === flight)
612
+ explicitAdoptionFlight = null;
613
+ resolveFlight(resolution);
614
+ },
615
+ };
616
+ explicitAdoptionFlight = flight;
617
+ if (!beginResolving(generation, token)) {
618
+ flight.settle({ status: "superseded" });
619
+ return;
234
620
  }
621
+ accessToken = token;
622
+ identity = null;
623
+ continuityBaseline = null;
624
+ pendingRefreshBaseline = null;
625
+ scheduleRefresh(expiresIn);
626
+ void startIdentityFlight(generation, token, null, false, false).then(flight.settle, () => flight.settle(markUnavailable(generation, token)));
235
627
  }
236
- const store = {
237
- getAccessToken() {
238
- return accessToken;
239
- },
240
- hasAccessToken() {
241
- return accessToken !== null;
242
- },
628
+ let store;
629
+ store = {
630
+ getAccessToken: () => accessToken,
631
+ hasAccessToken: () => accessToken !== null,
243
632
  setAccessToken(token, expiresIn) {
244
- applyToken(token);
245
- scheduleRefresh(expiresIn);
633
+ startExplicitToken(token, expiresIn);
246
634
  },
247
635
  clearAccessToken() {
248
- clearToken();
249
- if (refreshTimer) {
250
- clearTimeout(refreshTimer);
251
- refreshTimer = null;
636
+ supersedeExplicitAdoption();
637
+ if (clearCurrentGeneration(tokenGeneration)) {
638
+ const clearedGeneration = tokenGeneration;
639
+ emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
252
640
  }
253
- // Emits unconditionally so consumers can rely on `onLogout` as a
254
- // canonical logout signal regardless of prior token state.
255
- emitLogout();
256
641
  },
257
642
  async ensureAccessToken(forceRefresh = false) {
258
- if (!forceRefresh && accessToken)
643
+ if (!forceRefresh &&
644
+ accessToken &&
645
+ identity &&
646
+ !identityFlight &&
647
+ !refreshFlight &&
648
+ !authorityFlight &&
649
+ sessionState.status === "ready") {
259
650
  return accessToken;
260
- if (refreshPromise)
261
- return refreshPromise;
262
- refreshPromise = performRefresh().finally(() => {
263
- refreshPromise = null;
264
- });
265
- return refreshPromise;
651
+ }
652
+ const result = forceRefresh
653
+ ? await store.resolveSession({ refresh: true })
654
+ : await store.resolveSession();
655
+ return result.status === "ready" || result.status === "unavailable"
656
+ ? result.token
657
+ : null;
658
+ },
659
+ resolveSession(options = {}) {
660
+ const shouldRefresh = options.refresh === true ||
661
+ (options.refresh === undefined && accessToken === null);
662
+ const operation = explicitAdoptionFlight
663
+ ? explicitAdoptionFlight.promise
664
+ : refreshFlight
665
+ ? refreshFlight.promise
666
+ : shouldRefresh
667
+ ? runRefresh(tokenGeneration, currentBaseline())
668
+ : authorityFlight
669
+ ? authorityFlight.promise
670
+ : resolveCurrentSession();
671
+ return waitForCaller(operation, options.signal);
672
+ },
673
+ revalidateSession(options = {}) {
674
+ const operation = explicitAdoptionFlight
675
+ ? explicitAdoptionFlight.promise
676
+ : refreshFlight
677
+ ? refreshFlight.promise
678
+ : authorityFlight
679
+ ? authorityFlight.promise
680
+ : runRevalidate(tokenGeneration);
681
+ return waitForCaller(operation, options.signal);
266
682
  },
267
- /**
268
- * LOCAL-DEV ONLY (CEL-1364) — see the `AuthStore.devLogin` doc comment.
269
- *
270
- * Adoption deliberately routes through `store.setAccessToken()` rather than
271
- * touching `applyToken` / `scheduleRefresh` directly, so there is exactly
272
- * ONE token-adoption path shared with verify-otp: same identity fetch, same
273
- * refresh scheduling, same listener fan-out and ordering.
274
- */
275
683
  async devLogin(email) {
276
- let res;
684
+ let response;
277
685
  try {
278
- res = await fetch(`${baseUrl}/test/login`, {
686
+ response = await fetchAuthRequest(baseUrl, "/test/login", {
279
687
  method: "POST",
280
- // The route also sets the BFF session + refresh cookies the OTP flow
281
- // sets; `include` is what lets a subsequent `/auth/refresh` work.
282
688
  credentials: "include",
283
689
  headers: { "Content-Type": "application/json" },
284
690
  body: JSON.stringify({ email }),
@@ -292,8 +698,8 @@ export function createAuthStore(config) {
292
698
  message: DEV_LOGIN_MESSAGES.network,
293
699
  };
294
700
  }
295
- if (!res.ok) {
296
- if (res.status === 404) {
701
+ if (!response.ok) {
702
+ if (response.status === 404) {
297
703
  return {
298
704
  ok: false,
299
705
  reason: "test-endpoints-disabled",
@@ -301,7 +707,7 @@ export function createAuthStore(config) {
301
707
  message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
302
708
  };
303
709
  }
304
- if (res.status === 429) {
710
+ if (response.status === 429) {
305
711
  return {
306
712
  ok: false,
307
713
  reason: "rate-limited",
@@ -309,7 +715,7 @@ export function createAuthStore(config) {
309
715
  message: DEV_LOGIN_MESSAGES["rate-limited"],
310
716
  };
311
717
  }
312
- if (res.status === 403) {
718
+ if (response.status === 403) {
313
719
  return {
314
720
  ok: false,
315
721
  reason: "forbidden",
@@ -320,39 +726,30 @@ export function createAuthStore(config) {
320
726
  return {
321
727
  ok: false,
322
728
  reason: "unexpected",
323
- status: res.status,
324
- message: `Dev sign-in failed (HTTP ${res.status}).`,
729
+ status: response.status,
730
+ message: `Dev sign-in failed (HTTP ${response.status}).`,
325
731
  };
326
732
  }
327
- // Built once: three distinct malformed shapes converge on it below.
328
733
  const malformed = {
329
734
  ok: false,
330
735
  reason: "malformed-response",
331
- status: res.status,
736
+ status: response.status,
332
737
  message: DEV_LOGIN_MESSAGES["malformed-response"],
333
738
  };
334
- let parsed;
739
+ let raw;
335
740
  try {
336
- parsed = await res.json();
741
+ raw = await response.json();
337
742
  }
338
743
  catch {
339
744
  return malformed;
340
745
  }
341
- // `res.json()` resolving is not the same as "we got an object". A body of
342
- // literal `null` (or a bare string/number) parses fine, and
343
- // `extractAccessToken` dereferences its argument — so passing `null`
344
- // through would THROW out of a function whose result type promises it
345
- // never does, leaving the DEV button with no error channel at all.
346
- // Arrays fall through: they are objects, carry no token, and reach the
347
- // same `malformed` below.
348
- if (parsed === null || typeof parsed !== "object") {
746
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
349
747
  return malformed;
350
748
  }
351
- const json = parsed;
749
+ const json = raw;
352
750
  const token = extractAccessToken(json);
353
- if (!token) {
751
+ if (!token)
354
752
  return malformed;
355
- }
356
753
  const expiresIn = typeof json.expiresIn === "number"
357
754
  ? json.expiresIn
358
755
  : DEFAULT_ACCESS_TOKEN_TTL;
@@ -365,18 +762,22 @@ export function createAuthStore(config) {
365
762
  orgId: typeof json.orgId === "string" ? json.orgId : null,
366
763
  };
367
764
  },
368
- getUserId() {
369
- return identity?.userId ?? null;
370
- },
371
- getOrgId() {
372
- return identity?.orgId ?? null;
373
- },
374
- getUserType() {
375
- return identity?.userType ?? null;
376
- },
377
- getEntitlements() {
378
- // Defensive copy; [] when logged out or identity unresolved.
379
- return identity ? [...identity.entitlements] : [];
765
+ getUserId: () => identity?.id ?? null,
766
+ getOrgId: () => identity?.orgId ?? null,
767
+ getUserType: () => identity?.userType ?? null,
768
+ getEntitlements: () => identity?.entitlements ? [...identity.entitlements] : [],
769
+ getSessionState: () => copySessionState(sessionState),
770
+ onSessionStateChange(listener) {
771
+ sessionStateListeners.add(listener);
772
+ try {
773
+ listener(copySessionState(sessionState));
774
+ }
775
+ catch {
776
+ // Immediate delivery has the same isolation as later notifications.
777
+ }
778
+ return () => {
779
+ sessionStateListeners.delete(listener);
780
+ };
380
781
  },
381
782
  onOrgChange(listener) {
382
783
  orgChangeListeners.add(listener);