@cellarnode/auth 0.14.0 → 0.15.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,541 @@ 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", 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 explicitAdoptionFlight = null;
34
+ let tokenGeneration = 0;
62
35
  let previousOrgId = null;
63
36
  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;
37
+ // Last validated principal/tenant survives transient authority outages. It
38
+ // never powers authority getters; it only prevents raw same-token identity
39
+ // changes from becoming trusted after an unavailable read.
40
+ let continuityBaseline = null;
41
+ // A refreshed credential may be adopted before its identity read succeeds.
42
+ // Preserve its old-principal lineage so a later retry still verifies account
43
+ // continuity while permitting a fresh-token organisation transition.
44
+ let pendingRefreshBaseline = null;
45
+ let sessionState = { status: "unauthorized" };
69
46
  const orgChangeListeners = new Set();
70
47
  const accessTokenSetListeners = new Set();
71
48
  const logoutListeners = new Set();
72
- function emitOrgChange(orgId) {
49
+ const sessionStateListeners = new Set();
50
+ const stateQueue = [];
51
+ let publishingState = false;
52
+ function settleExplicitGeneration(generation, resolution) {
53
+ if (explicitAdoptionFlight?.generation === generation) {
54
+ explicitAdoptionFlight.settle(resolution);
55
+ }
56
+ }
57
+ function emitOrgChange(orgId, shouldContinue = () => true) {
73
58
  for (const listener of orgChangeListeners) {
74
59
  try {
75
60
  listener(orgId);
76
61
  }
77
62
  catch {
78
- // Swallow listener errors so a single bad subscriber cannot
79
- // break event fan-out to other subscribers.
63
+ // Subscriber failures cannot break other observers or session adoption.
80
64
  }
65
+ if (!shouldContinue())
66
+ break;
81
67
  }
82
68
  }
83
- function emitAccessTokenSet(token) {
69
+ function emitAccessTokenSet(token, shouldContinue = () => true) {
84
70
  for (const listener of accessTokenSetListeners) {
85
71
  try {
86
72
  listener(token);
87
73
  }
88
74
  catch {
89
- // Swallow.
75
+ // Subscriber failures cannot break other observers or session adoption.
90
76
  }
77
+ if (!shouldContinue())
78
+ break;
91
79
  }
92
80
  }
93
- function emitLogout() {
81
+ function emitLogout(shouldContinue = () => true) {
94
82
  for (const listener of logoutListeners) {
95
83
  try {
96
84
  listener();
97
85
  }
98
86
  catch {
99
- // Swallow.
87
+ // Subscriber failures cannot break other observers or logout.
100
88
  }
89
+ if (!shouldContinue())
90
+ break;
91
+ }
92
+ }
93
+ function publishSessionState(next) {
94
+ stateQueue.push(copySessionState(next));
95
+ if (publishingState)
96
+ return;
97
+ publishingState = true;
98
+ try {
99
+ while (stateQueue.length > 0) {
100
+ const published = stateQueue.shift();
101
+ if (!published)
102
+ continue;
103
+ sessionState = published;
104
+ for (const listener of sessionStateListeners) {
105
+ const queuedBeforeCallback = stateQueue.length;
106
+ try {
107
+ listener(copySessionState(published));
108
+ }
109
+ catch {
110
+ // Observers cannot veto or interrupt global session resolution.
111
+ }
112
+ if (stateQueue.length > queuedBeforeCallback)
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ finally {
118
+ publishingState = false;
101
119
  }
102
120
  }
121
+ /** Notify consumers before authority or credentials can change. */
122
+ function beginResolving(generation, token) {
123
+ publishSessionState({ status: "resolving", token });
124
+ if (generation !== tokenGeneration)
125
+ return false;
126
+ identity = null;
127
+ return true;
128
+ }
103
129
  function scheduleRefresh(expiresInSeconds) {
104
130
  if (refreshTimer)
105
131
  clearTimeout(refreshTimer);
106
132
  const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
107
133
  refreshTimer = setTimeout(() => {
108
- performRefresh();
134
+ void store.resolveSession({ refresh: true });
109
135
  }, delay);
110
136
  }
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
- */
137
+ function withResolutionTimeout(operation) {
138
+ const controller = new AbortController();
139
+ return new Promise((resolve, reject) => {
140
+ const timer = setTimeout(() => {
141
+ controller.abort();
142
+ reject(new Error("Session resolution request timed out"));
143
+ }, requestTimeoutMs);
144
+ let pending;
145
+ try {
146
+ pending = operation(controller.signal);
147
+ }
148
+ catch (error) {
149
+ clearTimeout(timer);
150
+ reject(error);
151
+ return;
152
+ }
153
+ void pending.then((value) => {
154
+ clearTimeout(timer);
155
+ resolve(value);
156
+ }, (error) => {
157
+ clearTimeout(timer);
158
+ reject(error);
159
+ });
160
+ });
161
+ }
162
+ function fetchResolutionResponse(path, init) {
163
+ return withResolutionTimeout(async (signal) => {
164
+ const response = await fetchAuthRequest(baseUrl, path, {
165
+ ...init,
166
+ signal,
167
+ });
168
+ const raw = response.ok ? await response.json() : null;
169
+ return { response, raw };
170
+ });
171
+ }
120
172
  async function fetchIdentity(token) {
173
+ let result;
121
174
  try {
122
- const res = await fetch(`${baseUrl}/auth/me`, {
175
+ result = await fetchResolutionResponse("/auth/me", {
123
176
  method: "GET",
124
177
  credentials: "include",
125
178
  headers: { Authorization: `Bearer ${token}` },
126
179
  });
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
180
  }
135
181
  catch {
136
- return null;
182
+ return { status: "unavailable" };
183
+ }
184
+ const { response, raw } = result;
185
+ if (response.status === 401 || response.status === 403) {
186
+ return { status: "unauthorized" };
137
187
  }
188
+ if (!response.ok)
189
+ return { status: "unavailable" };
190
+ const user = parseAuthUser(raw);
191
+ return user
192
+ ? { status: "ready", user }
193
+ : { status: "unavailable" };
138
194
  }
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;
195
+ function clearCurrentGeneration(generation) {
196
+ if (generation !== tokenGeneration)
197
+ return false;
198
+ tokenGeneration += 1;
199
+ const clearedGeneration = tokenGeneration;
200
+ accessToken = null;
201
+ identity = null;
202
+ continuityBaseline = null;
203
+ pendingRefreshBaseline = null;
204
+ identityFlight = null;
205
+ refreshFlight = null;
206
+ previousOrgId = null;
207
+ hasEmittedOrgId = false;
208
+ if (refreshTimer) {
209
+ clearTimeout(refreshTimer);
210
+ refreshTimer = null;
211
+ }
212
+ publishSessionState({ status: "unauthorized" });
213
+ if (tokenGeneration !== clearedGeneration || accessToken !== null)
214
+ return false;
215
+ emitAccessTokenSet(null, () => tokenGeneration === clearedGeneration && accessToken === null);
216
+ const cleared = tokenGeneration === clearedGeneration && accessToken === null;
217
+ if (cleared) {
218
+ settleExplicitGeneration(generation, { status: "unauthorized" });
219
+ }
220
+ return cleared;
221
+ }
222
+ function markUnavailable(generation, token) {
223
+ if (generation !== tokenGeneration || accessToken !== token) {
224
+ return { status: "superseded" };
225
+ }
226
+ identity = null;
227
+ publishSessionState({ status: "unavailable", token });
228
+ if (generation !== tokenGeneration || accessToken !== token) {
229
+ return { status: "superseded" };
230
+ }
231
+ const result = { status: "unavailable", token };
232
+ settleExplicitGeneration(generation, result);
233
+ return result;
234
+ }
235
+ function commitReady(generation, token, user) {
236
+ if (generation !== tokenGeneration || accessToken !== token) {
237
+ return { status: "superseded" };
238
+ }
239
+ identity = copyAuthUser(user);
240
+ const nextOrgId = identity.orgId;
241
+ continuityBaseline = { token, user: copyAuthUser(identity) };
242
+ pendingRefreshBaseline = null;
243
+ publishSessionState({ status: "ready", token, user: identity });
244
+ if (generation !== tokenGeneration || accessToken !== token) {
245
+ return { status: "superseded" };
246
+ }
247
+ emitAccessTokenSet(token, () => generation === tokenGeneration && accessToken === token);
248
+ if (generation !== tokenGeneration || accessToken !== token) {
249
+ return { status: "superseded" };
160
250
  }
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
251
  if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
170
252
  hasEmittedOrgId = true;
171
253
  previousOrgId = nextOrgId;
172
- emitOrgChange(nextOrgId);
254
+ emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
173
255
  }
256
+ if (generation !== tokenGeneration || accessToken !== token) {
257
+ return { status: "superseded" };
258
+ }
259
+ const result = {
260
+ status: "ready",
261
+ token,
262
+ user: copyAuthUser(identity),
263
+ };
264
+ settleExplicitGeneration(generation, result);
265
+ return result;
174
266
  }
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) {
183
- 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));
267
+ async function evaluateIdentity(generation, token, baseline, refreshed) {
268
+ const read = await fetchIdentity(token);
269
+ if (generation !== tokenGeneration || accessToken !== token) {
270
+ return { status: "superseded" };
271
+ }
272
+ if (read.status === "unauthorized") {
273
+ return clearCurrentGeneration(generation)
274
+ ? { status: "unauthorized" }
275
+ : { status: "superseded" };
276
+ }
277
+ if (read.status === "unavailable") {
278
+ return markUnavailable(generation, token);
279
+ }
280
+ if (baseline && read.user.id !== baseline.user.id) {
281
+ if (!refreshed)
282
+ return runRefresh(generation, baseline);
283
+ return clearCurrentGeneration(generation)
284
+ ? { status: "unauthorized" }
285
+ : { status: "superseded" };
286
+ }
287
+ if (baseline && read.user.orgId !== baseline.user.orgId) {
288
+ if (!refreshed) {
289
+ // Same-token raw membership changes are not authority. Rotate once,
290
+ // then validate fresh credentials before adopting the transition.
291
+ return runRefresh(generation, baseline);
292
+ }
293
+ if (token === baseline.token) {
294
+ return markUnavailable(generation, token);
295
+ }
296
+ }
297
+ return commitReady(generation, token, read.user);
195
298
  }
196
- /**
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.
202
- */
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);
299
+ function startIdentityFlight(generation, token, baseline, refreshed, notifyResolving) {
300
+ if (identityFlight?.generation === generation) {
301
+ return identityFlight.promise;
302
+ }
303
+ let settle;
304
+ const promise = new Promise((resolve) => {
305
+ settle = resolve;
306
+ });
307
+ const flight = { generation, promise };
308
+ identityFlight = flight;
309
+ if (notifyResolving && !beginResolving(generation, token)) {
310
+ if (identityFlight === flight)
311
+ identityFlight = null;
312
+ settle({ status: "superseded" });
313
+ return promise;
314
+ }
315
+ void evaluateIdentity(generation, token, baseline, refreshed).then((result) => {
316
+ if (identityFlight === flight)
317
+ identityFlight = null;
318
+ settle(result);
319
+ }, () => {
320
+ if (identityFlight === flight)
321
+ identityFlight = null;
322
+ settle(markUnavailable(generation, token));
323
+ });
324
+ return promise;
210
325
  }
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" },
326
+ function currentBaseline() {
327
+ const baseline = pendingRefreshBaseline ?? continuityBaseline;
328
+ return baseline
329
+ ? { token: baseline.token, user: copyAuthUser(baseline.user) }
330
+ : null;
331
+ }
332
+ function runRefresh(generation, baseline) {
333
+ if (refreshFlight)
334
+ return refreshFlight.promise;
335
+ if (generation !== tokenGeneration) {
336
+ return Promise.resolve({ status: "superseded" });
337
+ }
338
+ if (!baseline && identityFlight?.generation === generation) {
339
+ const pendingIdentity = identityFlight.promise;
340
+ return pendingIdentity.then((result) => {
341
+ if (result.status === "ready") {
342
+ return runRefresh(tokenGeneration, {
343
+ token: result.token,
344
+ user: copyAuthUser(result.user),
345
+ });
346
+ }
347
+ if (result.status === "unavailable" &&
348
+ generation === tokenGeneration &&
349
+ accessToken !== null) {
350
+ return runRefresh(tokenGeneration, null);
351
+ }
352
+ return result;
217
353
  });
218
- if (!res.ok) {
219
- clearToken();
220
- return null;
354
+ }
355
+ let settle;
356
+ const promise = new Promise((resolve) => {
357
+ settle = resolve;
358
+ });
359
+ const flight = { generation, promise };
360
+ refreshFlight = flight;
361
+ if (!beginResolving(generation, accessToken)) {
362
+ if (refreshFlight === flight)
363
+ refreshFlight = null;
364
+ settle({ status: "superseded" });
365
+ return promise;
366
+ }
367
+ // Refresh owns a new operation generation before transport begins. Any
368
+ // older identity read can no longer publish authority while refresh waits.
369
+ tokenGeneration += 1;
370
+ const refreshGeneration = tokenGeneration;
371
+ flight.generation = refreshGeneration;
372
+ void (async () => {
373
+ let result;
374
+ try {
375
+ result = await fetchResolutionResponse(refreshPath, {
376
+ method: "POST",
377
+ credentials: "include",
378
+ headers: { "Content-Type": "application/json" },
379
+ });
221
380
  }
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);
381
+ catch {
382
+ return markUnavailable(refreshGeneration, accessToken);
228
383
  }
229
- return token;
384
+ const { response, raw } = result;
385
+ if (refreshGeneration !== tokenGeneration)
386
+ return { status: "superseded" };
387
+ if (response.status === 401 || response.status === 403) {
388
+ return clearCurrentGeneration(refreshGeneration)
389
+ ? { status: "unauthorized" }
390
+ : { status: "superseded" };
391
+ }
392
+ if (!response.ok)
393
+ return markUnavailable(refreshGeneration, accessToken);
394
+ if (refreshGeneration !== tokenGeneration)
395
+ return { status: "superseded" };
396
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
397
+ return markUnavailable(refreshGeneration, accessToken);
398
+ }
399
+ const json = raw;
400
+ const nextToken = extractAccessToken(json);
401
+ if (!nextToken)
402
+ return markUnavailable(refreshGeneration, accessToken);
403
+ const expiresIn = typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn)
404
+ ? json.expiresIn
405
+ : DEFAULT_ACCESS_TOKEN_TTL;
406
+ tokenGeneration += 1;
407
+ const nextGeneration = tokenGeneration;
408
+ // Manual/timer callers arriving while refreshed identity still resolves
409
+ // join this same end-to-end refresh operation.
410
+ flight.generation = nextGeneration;
411
+ accessToken = nextToken;
412
+ identity = null;
413
+ pendingRefreshBaseline = baseline
414
+ ? { token: baseline.token, user: copyAuthUser(baseline.user) }
415
+ : null;
416
+ publishSessionState({ status: "resolving", token: nextToken });
417
+ if (nextGeneration !== tokenGeneration ||
418
+ accessToken !== nextToken) {
419
+ return { status: "superseded" };
420
+ }
421
+ scheduleRefresh(expiresIn);
422
+ return startIdentityFlight(nextGeneration, nextToken, baseline, true, false);
423
+ })().then((result) => {
424
+ if (refreshFlight === flight)
425
+ refreshFlight = null;
426
+ settle(result);
427
+ }, () => {
428
+ if (refreshFlight === flight)
429
+ refreshFlight = null;
430
+ settle(markUnavailable(refreshGeneration, accessToken));
431
+ });
432
+ return promise;
433
+ }
434
+ function resolveCurrentSession() {
435
+ const token = accessToken;
436
+ const generation = tokenGeneration;
437
+ if (!token)
438
+ return Promise.resolve({ status: "unauthorized" });
439
+ if (identityFlight?.generation === generation) {
440
+ return identityFlight.promise;
230
441
  }
231
- catch {
232
- clearToken();
233
- return null;
442
+ const baseline = currentBaseline();
443
+ const refreshed = pendingRefreshBaseline !== null;
444
+ return startIdentityFlight(generation, token, baseline, refreshed, true);
445
+ }
446
+ function waitForCaller(promise, signal) {
447
+ if (!signal)
448
+ return promise.then(copyResolution);
449
+ if (signal.aborted)
450
+ return Promise.resolve({ status: "superseded" });
451
+ return new Promise((resolve) => {
452
+ const onAbort = () => resolve({ status: "superseded" });
453
+ signal.addEventListener("abort", onAbort, { once: true });
454
+ void promise.then((result) => {
455
+ signal.removeEventListener("abort", onAbort);
456
+ resolve(copyResolution(result));
457
+ });
458
+ });
459
+ }
460
+ function supersedeExplicitAdoption() {
461
+ const flight = explicitAdoptionFlight;
462
+ if (!flight)
463
+ return;
464
+ explicitAdoptionFlight = null;
465
+ flight.settle({ status: "superseded" });
466
+ }
467
+ function startExplicitToken(token, expiresIn) {
468
+ tokenGeneration += 1;
469
+ const generation = tokenGeneration;
470
+ supersedeExplicitAdoption();
471
+ refreshFlight = null;
472
+ let settled = false;
473
+ let resolveFlight;
474
+ const promise = new Promise((resolve) => {
475
+ resolveFlight = resolve;
476
+ });
477
+ const flight = {
478
+ generation,
479
+ promise,
480
+ settle(resolution) {
481
+ if (settled)
482
+ return;
483
+ settled = true;
484
+ if (explicitAdoptionFlight === flight)
485
+ explicitAdoptionFlight = null;
486
+ resolveFlight(resolution);
487
+ },
488
+ };
489
+ explicitAdoptionFlight = flight;
490
+ if (!beginResolving(generation, token)) {
491
+ flight.settle({ status: "superseded" });
492
+ return;
234
493
  }
494
+ accessToken = token;
495
+ identity = null;
496
+ continuityBaseline = null;
497
+ pendingRefreshBaseline = null;
498
+ scheduleRefresh(expiresIn);
499
+ void startIdentityFlight(generation, token, null, false, false).then(flight.settle, () => flight.settle(markUnavailable(generation, token)));
235
500
  }
236
- const store = {
237
- getAccessToken() {
238
- return accessToken;
239
- },
240
- hasAccessToken() {
241
- return accessToken !== null;
242
- },
501
+ let store;
502
+ store = {
503
+ getAccessToken: () => accessToken,
504
+ hasAccessToken: () => accessToken !== null,
243
505
  setAccessToken(token, expiresIn) {
244
- applyToken(token);
245
- scheduleRefresh(expiresIn);
506
+ startExplicitToken(token, expiresIn);
246
507
  },
247
508
  clearAccessToken() {
248
- clearToken();
249
- if (refreshTimer) {
250
- clearTimeout(refreshTimer);
251
- refreshTimer = null;
509
+ supersedeExplicitAdoption();
510
+ if (clearCurrentGeneration(tokenGeneration)) {
511
+ const clearedGeneration = tokenGeneration;
512
+ emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
252
513
  }
253
- // Emits unconditionally so consumers can rely on `onLogout` as a
254
- // canonical logout signal regardless of prior token state.
255
- emitLogout();
256
514
  },
257
515
  async ensureAccessToken(forceRefresh = false) {
258
- if (!forceRefresh && accessToken)
516
+ if (!forceRefresh &&
517
+ accessToken &&
518
+ identity &&
519
+ !identityFlight &&
520
+ !refreshFlight &&
521
+ sessionState.status === "ready") {
259
522
  return accessToken;
260
- if (refreshPromise)
261
- return refreshPromise;
262
- refreshPromise = performRefresh().finally(() => {
263
- refreshPromise = null;
264
- });
265
- return refreshPromise;
523
+ }
524
+ const result = forceRefresh
525
+ ? await store.resolveSession({ refresh: true })
526
+ : await store.resolveSession();
527
+ return result.status === "ready" || result.status === "unavailable"
528
+ ? result.token
529
+ : null;
530
+ },
531
+ resolveSession(options = {}) {
532
+ const shouldRefresh = options.refresh === true ||
533
+ (options.refresh === undefined && accessToken === null);
534
+ const operation = explicitAdoptionFlight
535
+ ? explicitAdoptionFlight.promise
536
+ : refreshFlight
537
+ ? refreshFlight.promise
538
+ : shouldRefresh
539
+ ? runRefresh(tokenGeneration, currentBaseline())
540
+ : resolveCurrentSession();
541
+ return waitForCaller(operation, options.signal);
266
542
  },
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
543
  async devLogin(email) {
276
- let res;
544
+ let response;
277
545
  try {
278
- res = await fetch(`${baseUrl}/test/login`, {
546
+ response = await fetchAuthRequest(baseUrl, "/test/login", {
279
547
  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
548
  credentials: "include",
283
549
  headers: { "Content-Type": "application/json" },
284
550
  body: JSON.stringify({ email }),
@@ -292,8 +558,8 @@ export function createAuthStore(config) {
292
558
  message: DEV_LOGIN_MESSAGES.network,
293
559
  };
294
560
  }
295
- if (!res.ok) {
296
- if (res.status === 404) {
561
+ if (!response.ok) {
562
+ if (response.status === 404) {
297
563
  return {
298
564
  ok: false,
299
565
  reason: "test-endpoints-disabled",
@@ -301,7 +567,7 @@ export function createAuthStore(config) {
301
567
  message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
302
568
  };
303
569
  }
304
- if (res.status === 429) {
570
+ if (response.status === 429) {
305
571
  return {
306
572
  ok: false,
307
573
  reason: "rate-limited",
@@ -309,7 +575,7 @@ export function createAuthStore(config) {
309
575
  message: DEV_LOGIN_MESSAGES["rate-limited"],
310
576
  };
311
577
  }
312
- if (res.status === 403) {
578
+ if (response.status === 403) {
313
579
  return {
314
580
  ok: false,
315
581
  reason: "forbidden",
@@ -320,39 +586,30 @@ export function createAuthStore(config) {
320
586
  return {
321
587
  ok: false,
322
588
  reason: "unexpected",
323
- status: res.status,
324
- message: `Dev sign-in failed (HTTP ${res.status}).`,
589
+ status: response.status,
590
+ message: `Dev sign-in failed (HTTP ${response.status}).`,
325
591
  };
326
592
  }
327
- // Built once: three distinct malformed shapes converge on it below.
328
593
  const malformed = {
329
594
  ok: false,
330
595
  reason: "malformed-response",
331
- status: res.status,
596
+ status: response.status,
332
597
  message: DEV_LOGIN_MESSAGES["malformed-response"],
333
598
  };
334
- let parsed;
599
+ let raw;
335
600
  try {
336
- parsed = await res.json();
601
+ raw = await response.json();
337
602
  }
338
603
  catch {
339
604
  return malformed;
340
605
  }
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") {
606
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
349
607
  return malformed;
350
608
  }
351
- const json = parsed;
609
+ const json = raw;
352
610
  const token = extractAccessToken(json);
353
- if (!token) {
611
+ if (!token)
354
612
  return malformed;
355
- }
356
613
  const expiresIn = typeof json.expiresIn === "number"
357
614
  ? json.expiresIn
358
615
  : DEFAULT_ACCESS_TOKEN_TTL;
@@ -365,18 +622,22 @@ export function createAuthStore(config) {
365
622
  orgId: typeof json.orgId === "string" ? json.orgId : null,
366
623
  };
367
624
  },
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] : [];
625
+ getUserId: () => identity?.id ?? null,
626
+ getOrgId: () => identity?.orgId ?? null,
627
+ getUserType: () => identity?.userType ?? null,
628
+ getEntitlements: () => identity?.entitlements ? [...identity.entitlements] : [],
629
+ getSessionState: () => copySessionState(sessionState),
630
+ onSessionStateChange(listener) {
631
+ sessionStateListeners.add(listener);
632
+ try {
633
+ listener(copySessionState(sessionState));
634
+ }
635
+ catch {
636
+ // Immediate delivery has the same isolation as later notifications.
637
+ }
638
+ return () => {
639
+ sessionStateListeners.delete(listener);
640
+ };
380
641
  },
381
642
  onOrgChange(listener) {
382
643
  orgChangeListeners.add(listener);