@cellarnode/auth 0.13.3 → 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.
Files changed (41) hide show
  1. package/README.md +120 -4
  2. package/dist/auth-api.d.ts.map +1 -1
  3. package/dist/auth-api.js +44 -5
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/auth-client.d.ts.map +1 -1
  6. package/dist/auth-client.js +61 -17
  7. package/dist/auth-client.js.map +1 -1
  8. package/dist/auth-store.d.ts +2 -2
  9. package/dist/auth-store.d.ts.map +1 -1
  10. package/dist/auth-store.js +564 -173
  11. package/dist/auth-store.js.map +1 -1
  12. package/dist/auth-transport.d.ts +5 -0
  13. package/dist/auth-transport.d.ts.map +1 -0
  14. package/dist/auth-transport.js +45 -0
  15. package/dist/auth-transport.js.map +1 -0
  16. package/dist/auth-user.d.ts +7 -0
  17. package/dist/auth-user.d.ts.map +1 -0
  18. package/dist/auth-user.js +73 -0
  19. package/dist/auth-user.js.map +1 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/react/dev-sign-in.d.ts +54 -0
  25. package/dist/react/dev-sign-in.d.ts.map +1 -0
  26. package/dist/react/dev-sign-in.js +63 -0
  27. package/dist/react/dev-sign-in.js.map +1 -0
  28. package/dist/react/index.d.ts.map +1 -1
  29. package/dist/react/index.js +11 -0
  30. package/dist/react/index.js.map +1 -1
  31. package/dist/react/login-form.d.ts.map +1 -1
  32. package/dist/react/login-form.js +105 -3
  33. package/dist/react/login-form.js.map +1 -1
  34. package/dist/session-continuity.d.ts +13 -0
  35. package/dist/session-continuity.d.ts.map +1 -0
  36. package/dist/session-continuity.js +107 -0
  37. package/dist/session-continuity.js.map +1 -0
  38. package/dist/types.d.ts +130 -2
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js.map +1 -1
  41. package/package.json +2 -1
@@ -1,252 +1,643 @@
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
- };
3
+ import { fetchAuthRequest } from "./auth-transport.js";
4
+ const DEFAULT_ACCESS_TOKEN_TTL = 900;
5
+ const DEFAULT_RESOLUTION_TIMEOUT_MS = 10_000;
6
+ const DEV_LOGIN_MESSAGES = {
7
+ "test-endpoints-disabled": "Dev sign-in unavailable: backend test endpoints are disabled. Set ENABLE_TEST_ENDPOINTS=true on the API and restart it.",
8
+ "rate-limited": "Dev sign-in rate limit hit (5/min). Wait a minute and retry.",
9
+ forbidden: "Dev sign-in rejected: the API requires a fixture secret (TEST_FIXTURE_SECRET is set).",
10
+ network: "Dev sign-in could not reach the API. Is the backend running?",
11
+ "malformed-response": "Dev sign-in succeeded but the API returned no access token.",
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 };
19
22
  }
20
23
  export function createAuthStore(config) {
21
- 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;
22
28
  let accessToken = null;
23
29
  let identity = null;
24
30
  let refreshTimer = null;
25
- let refreshPromise = null;
26
- // Tracks the orgId observed at the last emitted state so `onOrgChange` only
27
- // fires on an actual transition. `hasEmittedOrgId` disambiguates the two
28
- // meanings `previousOrgId === null` would otherwise carry — "no org emitted
29
- // yet" vs "the current org is admin/null". Without it, the FIRST admin
30
- // identity (orgId null) and an admin login right after `clearToken()` would
31
- // skip `onOrgChange(null)`. Both are reset on logout so a subsequent login
32
- // re-fires `onOrgChange` even into the same org.
31
+ let identityFlight = null;
32
+ let refreshFlight = null;
33
+ let explicitAdoptionFlight = null;
34
+ let tokenGeneration = 0;
33
35
  let previousOrgId = null;
34
36
  let hasEmittedOrgId = false;
35
- // Monotonic generation counter. Bumped on every token change (set / clear /
36
- // refresh). An in-flight `/auth/me` resolution only commits if its captured
37
- // generation still matches — this discards stale identity results when the
38
- // token changed again before the previous fetch resolved.
39
- 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" };
40
46
  const orgChangeListeners = new Set();
41
47
  const accessTokenSetListeners = new Set();
42
48
  const logoutListeners = new Set();
43
- 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) {
44
58
  for (const listener of orgChangeListeners) {
45
59
  try {
46
60
  listener(orgId);
47
61
  }
48
62
  catch {
49
- // Swallow listener errors so a single bad subscriber cannot
50
- // break event fan-out to other subscribers.
63
+ // Subscriber failures cannot break other observers or session adoption.
51
64
  }
65
+ if (!shouldContinue())
66
+ break;
52
67
  }
53
68
  }
54
- function emitAccessTokenSet(token) {
69
+ function emitAccessTokenSet(token, shouldContinue = () => true) {
55
70
  for (const listener of accessTokenSetListeners) {
56
71
  try {
57
72
  listener(token);
58
73
  }
59
74
  catch {
60
- // Swallow.
75
+ // Subscriber failures cannot break other observers or session adoption.
61
76
  }
77
+ if (!shouldContinue())
78
+ break;
62
79
  }
63
80
  }
64
- function emitLogout() {
81
+ function emitLogout(shouldContinue = () => true) {
65
82
  for (const listener of logoutListeners) {
66
83
  try {
67
84
  listener();
68
85
  }
69
86
  catch {
70
- // Swallow.
87
+ // Subscriber failures cannot break other observers or logout.
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
+ }
71
115
  }
72
116
  }
117
+ finally {
118
+ publishingState = false;
119
+ }
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;
73
128
  }
74
129
  function scheduleRefresh(expiresInSeconds) {
75
130
  if (refreshTimer)
76
131
  clearTimeout(refreshTimer);
77
132
  const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0);
78
133
  refreshTimer = setTimeout(() => {
79
- performRefresh();
134
+ void store.resolveSession({ refresh: true });
80
135
  }, delay);
81
136
  }
82
- /**
83
- * Fetch `/auth/me` with the bearer token and return the parsed identity, or
84
- * null on any failure (network error, non-2xx, malformed body).
85
- *
86
- * Tolerant by design — mirrors how `performRefresh` swallows errors. A failed
87
- * `/auth/me` must NOT throw out of `setAccessToken` / `performRefresh`; the
88
- * caller treats null identity as "logged in but identity unknown" and the
89
- * getters fall back to null/[].
90
- */
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
+ }
91
172
  async function fetchIdentity(token) {
173
+ let result;
92
174
  try {
93
- const res = await fetch(`${baseUrl}/auth/me`, {
175
+ result = await fetchResolutionResponse("/auth/me", {
94
176
  method: "GET",
95
177
  credentials: "include",
96
178
  headers: { Authorization: `Bearer ${token}` },
97
179
  });
98
- if (!res.ok)
99
- return null;
100
- const user = (await res.json());
101
- if (!user || typeof user.id !== "string" || user.id.length === 0) {
102
- return null;
103
- }
104
- return toIdentity(user);
105
180
  }
106
181
  catch {
107
- return null;
182
+ return { status: "unavailable" };
183
+ }
184
+ const { response, raw } = result;
185
+ if (response.status === 401 || response.status === 403) {
186
+ return { status: "unauthorized" };
108
187
  }
188
+ if (!response.ok)
189
+ return { status: "unavailable" };
190
+ const user = parseAuthUser(raw);
191
+ return user
192
+ ? { status: "ready", user }
193
+ : { status: "unavailable" };
109
194
  }
110
- /**
111
- * Commit a freshly-resolved identity and emit the dependent events.
112
- *
113
- * EVENT ORDERING (CRITICAL — consumer realtime hooks depend on it):
114
- * identity is async, so on a token change the sequence is:
115
- * 1. set token (synchronous, in `setAccessToken` / `performRefresh`)
116
- * 2. kick off `fetchIdentity(token)` (async)
117
- * 3. ON RESOLVE → here:
118
- * a. cache identity FIRST (so getters return real values)
119
- * b. emit `onAccessTokenSet(token)` (consumer hooks re-read getOrgId()
120
- * inside this handler — identity must already be cached)
121
- * c. emit `onOrgChange(orgId)` IF orgId actually changed vs the last
122
- * emitted state
123
- *
124
- * The `generation` guard discards this commit if the token changed again
125
- * (set/clear/refresh) while this `/auth/me` was in flight.
126
- */
127
- function commitIdentity(generation, token, nextIdentity) {
128
- if (generation !== tokenGeneration) {
129
- // A newer token change superseded this fetch — drop the stale result.
130
- 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" };
131
250
  }
132
- identity = nextIdentity;
133
- const nextOrgId = nextIdentity?.orgId ?? null;
134
- // (b) Token-set first — handlers read getOrgId()/getUserId() and must see
135
- // the just-cached identity.
136
- emitAccessTokenSet(token);
137
- // (c) Org-change on the first emission OR an actual transition. The
138
- // `!hasEmittedOrgId` guard ensures the first identity (incl. an admin
139
- // orgId of null) always fires once.
140
251
  if (!hasEmittedOrgId || nextOrgId !== previousOrgId) {
141
252
  hasEmittedOrgId = true;
142
253
  previousOrgId = nextOrgId;
143
- emitOrgChange(nextOrgId);
254
+ emitOrgChange(nextOrgId, () => generation === tokenGeneration && accessToken === token);
255
+ }
256
+ if (generation !== tokenGeneration || accessToken !== token) {
257
+ return { status: "superseded" };
144
258
  }
259
+ const result = {
260
+ status: "ready",
261
+ token,
262
+ user: copyAuthUser(identity),
263
+ };
264
+ settleExplicitGeneration(generation, result);
265
+ return result;
145
266
  }
146
- /**
147
- * Apply a new access token, kicking off async identity resolution.
148
- *
149
- * The token is committed synchronously (so `getAccessToken()` is immediately
150
- * correct); identity + `onAccessTokenSet` + `onOrgChange` settle only after
151
- * `/auth/me` resolves (see `commitIdentity`).
152
- */
153
- function applyToken(token) {
154
- tokenGeneration += 1;
155
- const generation = tokenGeneration;
156
- accessToken = token;
157
- // Clear the prior session's identity synchronously so the getters return
158
- // the documented null/[] defaults until the fresh /auth/me resolves —
159
- // otherwise a token switch (e.g. logging in as a different user) would leak
160
- // the previous session's userId/orgId/entitlements until the new fetch
161
- // lands. The realtime consumer hooks read the getters on events (not by
162
- // polling), so this introduces no flicker: a same-user refresh re-commits
163
- // the same orgId without firing `onOrgChange`.
164
- identity = null;
165
- 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);
166
298
  }
167
- /**
168
- * Clear all token + identity state synchronously and reset org tracking.
169
- *
170
- * `onAccessTokenSet(null)` fires synchronously (no `/auth/me` round-trip on
171
- * logout). `previousOrgId` is reset so a subsequent login re-fires
172
- * `onOrgChange` even when the user logs back into the same org.
173
- */
174
- function clearToken() {
175
- tokenGeneration += 1; // invalidate any in-flight /auth/me
176
- accessToken = null;
177
- identity = null;
178
- previousOrgId = null;
179
- hasEmittedOrgId = false;
180
- 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;
181
325
  }
182
- async function performRefresh() {
183
- try {
184
- const res = await fetch(`${baseUrl}${refreshPath}`, {
185
- method: "POST",
186
- credentials: "include",
187
- 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;
188
353
  });
189
- if (!res.ok) {
190
- clearToken();
191
- 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
+ });
192
380
  }
193
- const json = (await res.json());
194
- const token = extractAccessToken(json);
195
- if (token) {
196
- const expiresIn = typeof json.expiresIn === "number" ? json.expiresIn : 900;
197
- applyToken(token);
198
- scheduleRefresh(expiresIn);
381
+ catch {
382
+ return markUnavailable(refreshGeneration, accessToken);
383
+ }
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" };
199
391
  }
200
- return token;
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;
201
441
  }
202
- catch {
203
- clearToken();
204
- 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;
205
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)));
206
500
  }
207
- const store = {
208
- getAccessToken() {
209
- return accessToken;
210
- },
211
- hasAccessToken() {
212
- return accessToken !== null;
213
- },
501
+ let store;
502
+ store = {
503
+ getAccessToken: () => accessToken,
504
+ hasAccessToken: () => accessToken !== null,
214
505
  setAccessToken(token, expiresIn) {
215
- applyToken(token);
216
- scheduleRefresh(expiresIn);
506
+ startExplicitToken(token, expiresIn);
217
507
  },
218
508
  clearAccessToken() {
219
- clearToken();
220
- if (refreshTimer) {
221
- clearTimeout(refreshTimer);
222
- refreshTimer = null;
509
+ supersedeExplicitAdoption();
510
+ if (clearCurrentGeneration(tokenGeneration)) {
511
+ const clearedGeneration = tokenGeneration;
512
+ emitLogout(() => tokenGeneration === clearedGeneration && accessToken === null);
223
513
  }
224
- // Emits unconditionally so consumers can rely on `onLogout` as a
225
- // canonical logout signal regardless of prior token state.
226
- emitLogout();
227
514
  },
228
515
  async ensureAccessToken(forceRefresh = false) {
229
- if (!forceRefresh && accessToken)
516
+ if (!forceRefresh &&
517
+ accessToken &&
518
+ identity &&
519
+ !identityFlight &&
520
+ !refreshFlight &&
521
+ sessionState.status === "ready") {
230
522
  return accessToken;
231
- if (refreshPromise)
232
- return refreshPromise;
233
- refreshPromise = performRefresh().finally(() => {
234
- refreshPromise = null;
235
- });
236
- return refreshPromise;
237
- },
238
- getUserId() {
239
- return identity?.userId ?? null;
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;
240
530
  },
241
- getOrgId() {
242
- return identity?.orgId ?? null;
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);
243
542
  },
244
- getUserType() {
245
- return identity?.userType ?? null;
543
+ async devLogin(email) {
544
+ let response;
545
+ try {
546
+ response = await fetchAuthRequest(baseUrl, "/test/login", {
547
+ method: "POST",
548
+ credentials: "include",
549
+ headers: { "Content-Type": "application/json" },
550
+ body: JSON.stringify({ email }),
551
+ });
552
+ }
553
+ catch {
554
+ return {
555
+ ok: false,
556
+ reason: "network",
557
+ status: null,
558
+ message: DEV_LOGIN_MESSAGES.network,
559
+ };
560
+ }
561
+ if (!response.ok) {
562
+ if (response.status === 404) {
563
+ return {
564
+ ok: false,
565
+ reason: "test-endpoints-disabled",
566
+ status: 404,
567
+ message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"],
568
+ };
569
+ }
570
+ if (response.status === 429) {
571
+ return {
572
+ ok: false,
573
+ reason: "rate-limited",
574
+ status: 429,
575
+ message: DEV_LOGIN_MESSAGES["rate-limited"],
576
+ };
577
+ }
578
+ if (response.status === 403) {
579
+ return {
580
+ ok: false,
581
+ reason: "forbidden",
582
+ status: 403,
583
+ message: DEV_LOGIN_MESSAGES.forbidden,
584
+ };
585
+ }
586
+ return {
587
+ ok: false,
588
+ reason: "unexpected",
589
+ status: response.status,
590
+ message: `Dev sign-in failed (HTTP ${response.status}).`,
591
+ };
592
+ }
593
+ const malformed = {
594
+ ok: false,
595
+ reason: "malformed-response",
596
+ status: response.status,
597
+ message: DEV_LOGIN_MESSAGES["malformed-response"],
598
+ };
599
+ let raw;
600
+ try {
601
+ raw = await response.json();
602
+ }
603
+ catch {
604
+ return malformed;
605
+ }
606
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
607
+ return malformed;
608
+ }
609
+ const json = raw;
610
+ const token = extractAccessToken(json);
611
+ if (!token)
612
+ return malformed;
613
+ const expiresIn = typeof json.expiresIn === "number"
614
+ ? json.expiresIn
615
+ : DEFAULT_ACCESS_TOKEN_TTL;
616
+ store.setAccessToken(token, expiresIn);
617
+ return {
618
+ ok: true,
619
+ accessToken: token,
620
+ expiresIn,
621
+ userId: typeof json.userId === "string" ? json.userId : null,
622
+ orgId: typeof json.orgId === "string" ? json.orgId : null,
623
+ };
246
624
  },
247
- getEntitlements() {
248
- // Defensive copy; [] when logged out or identity unresolved.
249
- 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
+ };
250
641
  },
251
642
  onOrgChange(listener) {
252
643
  orgChangeListeners.add(listener);