@somewhere-cloud/sdk 0.0.0-stage → 0.13.1

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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/dist/cjs/auth/client.js +1142 -0
  4. package/dist/cjs/auth/client.js.map +1 -0
  5. package/dist/cjs/auth/continuation.js +27 -0
  6. package/dist/cjs/auth/continuation.js.map +1 -0
  7. package/dist/cjs/auth/index.js +21 -0
  8. package/dist/cjs/auth/index.js.map +1 -0
  9. package/dist/cjs/auth/react.js +295 -0
  10. package/dist/cjs/auth/react.js.map +1 -0
  11. package/dist/cjs/auth/server.js +368 -0
  12. package/dist/cjs/auth/server.js.map +1 -0
  13. package/dist/cjs/client.js +392 -0
  14. package/dist/cjs/client.js.map +1 -0
  15. package/dist/cjs/errors.js +16 -0
  16. package/dist/cjs/errors.js.map +1 -0
  17. package/dist/cjs/index.cjs +215 -0
  18. package/dist/cjs/index.js.map +1 -0
  19. package/dist/cjs/package.json +1 -0
  20. package/dist/cjs/query-cache.js +91 -0
  21. package/dist/cjs/query-cache.js.map +1 -0
  22. package/dist/cjs/resources/auth.js +875 -0
  23. package/dist/cjs/resources/auth.js.map +1 -0
  24. package/dist/cjs/resources/calls.js +42 -0
  25. package/dist/cjs/resources/calls.js.map +1 -0
  26. package/dist/cjs/resources/chat.js +82 -0
  27. package/dist/cjs/resources/chat.js.map +1 -0
  28. package/dist/cjs/resources/db.js +124 -0
  29. package/dist/cjs/resources/db.js.map +1 -0
  30. package/dist/cjs/resources/emails.js +39 -0
  31. package/dist/cjs/resources/emails.js.map +1 -0
  32. package/dist/cjs/resources/fs.js +141 -0
  33. package/dist/cjs/resources/fs.js.map +1 -0
  34. package/dist/cjs/resources/functions.js +145 -0
  35. package/dist/cjs/resources/functions.js.map +1 -0
  36. package/dist/cjs/resources/inbox.js +76 -0
  37. package/dist/cjs/resources/inbox.js.map +1 -0
  38. package/dist/cjs/resources/payments.js +68 -0
  39. package/dist/cjs/resources/payments.js.map +1 -0
  40. package/dist/cjs/resources/postgrest.js +391 -0
  41. package/dist/cjs/resources/postgrest.js.map +1 -0
  42. package/dist/cjs/resources/projects.js +71 -0
  43. package/dist/cjs/resources/projects.js.map +1 -0
  44. package/dist/cjs/resources/storage.js +191 -0
  45. package/dist/cjs/resources/storage.js.map +1 -0
  46. package/dist/cjs/resources/tasks.js +99 -0
  47. package/dist/cjs/resources/tasks.js.map +1 -0
  48. package/dist/cjs/resources/video.js +50 -0
  49. package/dist/cjs/resources/video.js.map +1 -0
  50. package/dist/cjs/types.js +3 -0
  51. package/dist/cjs/types.js.map +1 -0
  52. package/dist/esm/auth/client.js +1137 -0
  53. package/dist/esm/auth/client.js.map +1 -0
  54. package/dist/esm/auth/continuation.js +22 -0
  55. package/dist/esm/auth/continuation.js.map +1 -0
  56. package/dist/esm/auth/index.js +5 -0
  57. package/dist/esm/auth/index.js.map +1 -0
  58. package/dist/esm/auth/react.js +279 -0
  59. package/dist/esm/auth/react.js.map +1 -0
  60. package/dist/esm/auth/server.js +365 -0
  61. package/dist/esm/auth/server.js.map +1 -0
  62. package/dist/esm/client.js +388 -0
  63. package/dist/esm/client.js.map +1 -0
  64. package/dist/esm/errors.js +12 -0
  65. package/dist/esm/errors.js.map +1 -0
  66. package/dist/esm/index.js +188 -0
  67. package/dist/esm/index.js.map +1 -0
  68. package/dist/esm/package.json +1 -0
  69. package/dist/esm/query-cache.js +87 -0
  70. package/dist/esm/query-cache.js.map +1 -0
  71. package/dist/esm/resources/auth.js +871 -0
  72. package/dist/esm/resources/auth.js.map +1 -0
  73. package/dist/esm/resources/calls.js +38 -0
  74. package/dist/esm/resources/calls.js.map +1 -0
  75. package/dist/esm/resources/chat.js +77 -0
  76. package/dist/esm/resources/chat.js.map +1 -0
  77. package/dist/esm/resources/db.js +120 -0
  78. package/dist/esm/resources/db.js.map +1 -0
  79. package/dist/esm/resources/emails.js +35 -0
  80. package/dist/esm/resources/emails.js.map +1 -0
  81. package/dist/esm/resources/fs.js +137 -0
  82. package/dist/esm/resources/fs.js.map +1 -0
  83. package/dist/esm/resources/functions.js +141 -0
  84. package/dist/esm/resources/functions.js.map +1 -0
  85. package/dist/esm/resources/inbox.js +70 -0
  86. package/dist/esm/resources/inbox.js.map +1 -0
  87. package/dist/esm/resources/payments.js +64 -0
  88. package/dist/esm/resources/payments.js.map +1 -0
  89. package/dist/esm/resources/postgrest.js +386 -0
  90. package/dist/esm/resources/postgrest.js.map +1 -0
  91. package/dist/esm/resources/projects.js +67 -0
  92. package/dist/esm/resources/projects.js.map +1 -0
  93. package/dist/esm/resources/storage.js +186 -0
  94. package/dist/esm/resources/storage.js.map +1 -0
  95. package/dist/esm/resources/tasks.js +95 -0
  96. package/dist/esm/resources/tasks.js.map +1 -0
  97. package/dist/esm/resources/video.js +46 -0
  98. package/dist/esm/resources/video.js.map +1 -0
  99. package/dist/esm/types.js +2 -0
  100. package/dist/esm/types.js.map +1 -0
  101. package/dist/types/auth/client.d.ts +272 -0
  102. package/dist/types/auth/client.d.ts.map +1 -0
  103. package/dist/types/auth/continuation.d.ts +10 -0
  104. package/dist/types/auth/continuation.d.ts.map +1 -0
  105. package/dist/types/auth/index.d.ts +2 -0
  106. package/dist/types/auth/index.d.ts.map +1 -0
  107. package/dist/types/auth/react.d.ts +140 -0
  108. package/dist/types/auth/react.d.ts.map +1 -0
  109. package/dist/types/auth/server.d.ts +124 -0
  110. package/dist/types/auth/server.d.ts.map +1 -0
  111. package/dist/types/client.d.ts +128 -0
  112. package/dist/types/client.d.ts.map +1 -0
  113. package/dist/types/errors.d.ts +17 -0
  114. package/dist/types/errors.d.ts.map +1 -0
  115. package/dist/types/index.d.ts +145 -0
  116. package/dist/types/index.d.ts.map +1 -0
  117. package/dist/types/query-cache.d.ts +59 -0
  118. package/dist/types/query-cache.d.ts.map +1 -0
  119. package/dist/types/resources/auth.d.ts +188 -0
  120. package/dist/types/resources/auth.d.ts.map +1 -0
  121. package/dist/types/resources/calls.d.ts +27 -0
  122. package/dist/types/resources/calls.d.ts.map +1 -0
  123. package/dist/types/resources/chat.d.ts +24 -0
  124. package/dist/types/resources/chat.d.ts.map +1 -0
  125. package/dist/types/resources/db.d.ts +81 -0
  126. package/dist/types/resources/db.d.ts.map +1 -0
  127. package/dist/types/resources/emails.d.ts +20 -0
  128. package/dist/types/resources/emails.d.ts.map +1 -0
  129. package/dist/types/resources/fs.d.ts +68 -0
  130. package/dist/types/resources/fs.d.ts.map +1 -0
  131. package/dist/types/resources/functions.d.ts +30 -0
  132. package/dist/types/resources/functions.d.ts.map +1 -0
  133. package/dist/types/resources/inbox.d.ts +52 -0
  134. package/dist/types/resources/inbox.d.ts.map +1 -0
  135. package/dist/types/resources/payments.d.ts +31 -0
  136. package/dist/types/resources/payments.d.ts.map +1 -0
  137. package/dist/types/resources/postgrest.d.ts +121 -0
  138. package/dist/types/resources/postgrest.d.ts.map +1 -0
  139. package/dist/types/resources/projects.d.ts +79 -0
  140. package/dist/types/resources/projects.d.ts.map +1 -0
  141. package/dist/types/resources/storage.d.ts +63 -0
  142. package/dist/types/resources/storage.d.ts.map +1 -0
  143. package/dist/types/resources/tasks.d.ts +87 -0
  144. package/dist/types/resources/tasks.d.ts.map +1 -0
  145. package/dist/types/resources/video.d.ts +30 -0
  146. package/dist/types/resources/video.d.ts.map +1 -0
  147. package/dist/types/types.d.ts +379 -0
  148. package/dist/types/types.d.ts.map +1 -0
  149. package/package.json +86 -4
@@ -0,0 +1,1137 @@
1
+ import { isAuthUser, mfaChallenge } from './continuation.js';
2
+ const ACCESS_ROTATE_HEADER = 'X-New-Access-Token';
3
+ const REFRESH_ROTATE_HEADER = 'X-New-Refresh-Token';
4
+ const RIDE_ALONG_HEADER = 'X-Refresh-Token';
5
+ // Sent on every sign-in call: `cookie` when this client prefers cookies (a
6
+ // cookie-capable backend sets the httpOnly pair and replies
7
+ // `cookie_session: true`; one that ignores the hint is refused with
8
+ // COOKIE_SESSION_NOT_CONFIRMED), `header` in header mode — the explicit
9
+ // opt-in the packaged handler requires before it returns tokens.
10
+ const MODE_HINT_HEADER = 'X-Sw-Auth-Mode';
11
+ function memoryStorage() {
12
+ const m = new Map();
13
+ return {
14
+ getItem: (k) => (m.has(k) ? m.get(k) : null),
15
+ setItem: (k, v) => void m.set(k, v),
16
+ removeItem: (k) => void m.delete(k),
17
+ };
18
+ }
19
+ function defaultStorage() {
20
+ try {
21
+ if (typeof localStorage !== 'undefined')
22
+ return localStorage;
23
+ }
24
+ catch {
25
+ /* access can throw in sandboxed iframes */
26
+ }
27
+ return memoryStorage();
28
+ }
29
+ /** The social callback's public attempt id: passed explicitly, or read from the
30
+ * callback page's own URL. It only selects which of this browser's attempt
31
+ * cookies the server presents; the platform proves it before the code counts. */
32
+ function oauthAttempt(input) {
33
+ if (typeof input.attempt === 'string')
34
+ return { attempt: input.attempt };
35
+ if (typeof window === 'undefined')
36
+ return {};
37
+ const fromUrl = new URL(window.location.href).searchParams.get('attempt');
38
+ return fromUrl ? { attempt: fromUrl } : {};
39
+ }
40
+ /** undefined → 30000; anything else must be 1..2147483647 ms (setTimeout's range). */
41
+ function mutationTimeout(value) {
42
+ if (value === undefined)
43
+ return 30000;
44
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 2147483647) {
45
+ throw new RangeError('mutationTimeoutMs must be a number of milliseconds from 1 to 2147483647.');
46
+ }
47
+ return value;
48
+ }
49
+ export function createSomewhereAuth(options = {}) {
50
+ const baseUrl = (options.baseUrl ?? '').replace(/\/$/, '');
51
+ const authPath = (options.authPath ?? '/api/auth').replace(/\/$/, '');
52
+ const storage = options.storage ?? defaultStorage();
53
+ const key = options.storageKey ?? 'sw_auth';
54
+ const userKey = `${key}_user`; // cached user JSON for optimistic restore (tsk_1288e1c6)
55
+ // ONLY-PATH CONTRACT (tsk_acd56ee8): browsers use cookie sessions and keep
56
+ // credentials out of page JavaScript. Header mode exists only for non-browser
57
+ // clients and rule-9 compatibility with pre-0.2.0/older-handler sessions.
58
+ const preferCookie = options.mode ? options.mode === 'cookie' : typeof document !== 'undefined';
59
+ // No credentials: retain logout intent across navigation, including navigation
60
+ // before the queued request starts. Only a confirmed new sign-in replaces it.
61
+ const signOutKey = `${userKey}:sign-out:${baseUrl}${authPath}`;
62
+ let signOutIntent = null;
63
+ let signOutStorageAvailable = true;
64
+ function readSignOutIntent() {
65
+ if (preferCookie && signOutStorageAvailable) {
66
+ try {
67
+ signOutIntent = storage.getItem(signOutKey);
68
+ }
69
+ catch { /* retain memory state */ }
70
+ }
71
+ return signOutIntent;
72
+ }
73
+ function writeSignOutIntent(next) {
74
+ signOutIntent = next;
75
+ if (preferCookie) {
76
+ try {
77
+ if (next === null)
78
+ storage.removeItem(signOutKey);
79
+ else
80
+ storage.setItem(signOutKey, next);
81
+ }
82
+ catch {
83
+ signOutStorageAvailable = false;
84
+ }
85
+ }
86
+ }
87
+ const restoredSignOut = readSignOutIntent();
88
+ let session = restoredSignOut ? null : loadSession();
89
+ // Optimistic restore: seed the user from cache so a fresh page load paints
90
+ // the signed-in UI immediately and a transient /me failure (offline, 5xx)
91
+ // never flashes logged-out. In header mode only trust the cache when a
92
+ // session exists — no tokens means no user, don't show a ghost. In cookie
93
+ // mode the tokens are httpOnly (unreadable here), so the cached user IS our
94
+ // best knowledge of the session; /me validates it in the background.
95
+ //
96
+ // Note: when `session` is non-null under preferCookie, this is an EXISTING
97
+ // header-mode session from a pre-0.2.0 install. We adopt it as-is (header
98
+ // transport) so the upgrade logs nobody out; it migrates to cookies on the
99
+ // next sign-in.
100
+ let pendingMfa = null;
101
+ let user = !restoredSignOut && (session || preferCookie) ? loadUser() : null;
102
+ // A cached user is unverified until the backend answers.
103
+ let status = !restoredSignOut && (session || preferCookie) ? 'checking' : 'signed-out';
104
+ let error = restoredSignOut?.startsWith('pending:')
105
+ ? new AuthError('Sign-out has not been confirmed by the server.', 0, 'SIGN_OUT_UNCONFIRMED') : null;
106
+ // Advances when an identity change is REQUESTED (sign-in family, signOut) or
107
+ // arrives from another tab. Responses to requests started under an earlier
108
+ // generation never write user, session or status.
109
+ let generation = 0;
110
+ // Cookie-changing calls run one at a time, in call order. The chain keeps
111
+ // going after a rejection; each call is bounded by mutationTimeoutMs.
112
+ let mutations = Promise.resolve();
113
+ let pendingMutations = 0;
114
+ const mutationTimeoutMs = mutationTimeout(options.mutationTimeoutMs);
115
+ // A sign-out the server has not confirmed. It stays owed (state
116
+ // signOutUnconfirmed + error) and the next signOut() retries it, with the
117
+ // header session it was for, until /logout succeeds or a sign-in replaces
118
+ // the session. Cookie clients retain intent across reload as well.
119
+ let owedSignOut = error ? { session: null, error } : null;
120
+ // signOut() calls whose /logout has not settled yet (AuthState.signingOut).
121
+ let signOutsInFlight = 0;
122
+ // At most one /me per generation; concurrent getUser() calls share it.
123
+ let check = null;
124
+ const listeners = new Set();
125
+ let refreshFlight = null;
126
+ // True while the in-flight refresh was started in the background (timer or focus), while the
127
+ // held access token was still valid. Requests then need not wait for it (see authFetch).
128
+ let refreshInBackground = false;
129
+ let refreshTimer;
130
+ let nextRefreshAt = 0;
131
+ const refreshStampKey = userKey + ':refresh-at';
132
+ const refreshPendingKey = userKey + ':refresh-pending';
133
+ function beginRefresh() {
134
+ if (storage.getItem(refreshPendingKey))
135
+ throw new AuthError('Another refresh has an unknown outcome. Sign in again.', 0, 'REFRESH_OUTCOME_UNKNOWN');
136
+ const marker = String(Date.now()) + ':' + Math.random();
137
+ storage.setItem(refreshPendingKey, marker);
138
+ return marker;
139
+ }
140
+ /** A pending marker seen while holding the refresh lock belongs to no live refresh: one that was
141
+ * cut off (a page change or crash mid-request), so its rotation may or may not have committed. */
142
+ function refreshOrphaned() { try {
143
+ return storage.getItem(refreshPendingKey) !== null;
144
+ }
145
+ catch {
146
+ return false;
147
+ } }
148
+ function confirmRefresh(marker) { try {
149
+ if (marker === undefined || storage.getItem(refreshPendingKey) === marker)
150
+ storage.removeItem(refreshPendingKey);
151
+ }
152
+ catch { /* a new sign-in still replaces local credentials */ } }
153
+ let accessExpiresAt = session ? tokenExpiry(session.accessToken) : 0;
154
+ let refreshUncertain = false;
155
+ function tokenExpiry(token) {
156
+ try {
157
+ return Number(JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))).exp) * 1000 || 0;
158
+ }
159
+ catch {
160
+ return 0;
161
+ }
162
+ }
163
+ function loadSession() {
164
+ try {
165
+ const raw = storage.getItem(key);
166
+ if (!raw)
167
+ return null;
168
+ const parsed = JSON.parse(raw);
169
+ if (parsed && typeof parsed.accessToken === 'string' && typeof parsed.refreshToken === 'string') {
170
+ return { accessToken: parsed.accessToken, refreshToken: parsed.refreshToken };
171
+ }
172
+ }
173
+ catch {
174
+ /* corrupt → treat as logged out */
175
+ }
176
+ return null;
177
+ }
178
+ function loadUser() {
179
+ try {
180
+ const raw = storage.getItem(userKey);
181
+ if (!raw)
182
+ return null;
183
+ const parsed = JSON.parse(raw);
184
+ if (isAuthUser(parsed))
185
+ return parsed;
186
+ storage.removeItem(userKey);
187
+ return null;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ function snapshot() {
194
+ return { status, user, session, error, signOutUnconfirmed: owedSignOut !== null, signingOut: signOutsInFlight > 0 };
195
+ }
196
+ function emit() {
197
+ const state = snapshot();
198
+ for (const l of listeners)
199
+ l(state);
200
+ }
201
+ /** ATOMIC: persist the whole session as one value, or clear it. There is no
202
+ * code path that writes one token without the other. */
203
+ function writeSession(next) {
204
+ session = next;
205
+ accessExpiresAt = next ? tokenExpiry(next.accessToken) : 0;
206
+ try {
207
+ if (next)
208
+ storage.setItem(key, JSON.stringify(next));
209
+ else
210
+ storage.removeItem(key);
211
+ }
212
+ catch {
213
+ /* storage unavailable — keep the in-memory session anyway */
214
+ }
215
+ }
216
+ function writeUser(next) {
217
+ user = next;
218
+ try {
219
+ const raw = next ? JSON.stringify(next) : null;
220
+ // Identical writes are skipped so other tabs only hear real changes.
221
+ if (storage.getItem(userKey) !== raw) {
222
+ if (raw === null)
223
+ storage.removeItem(userKey);
224
+ else
225
+ storage.setItem(userKey, raw);
226
+ }
227
+ }
228
+ catch {
229
+ /* storage unavailable — keep the in-memory user anyway */
230
+ }
231
+ }
232
+ function setSession(next) {
233
+ writeSession(next);
234
+ emit();
235
+ }
236
+ /** A definitive state. An owed sign-out keeps reporting its error. */
237
+ function settle(next, nextUser) {
238
+ if (next === 'authenticated' && !isAuthUser(nextUser))
239
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
240
+ writeUser(nextUser);
241
+ status = next;
242
+ scheduleRefresh();
243
+ error = owedSignOut?.error ?? null;
244
+ emit();
245
+ }
246
+ /** A check failed without an answer: keep the last-known user, unverified. */
247
+ function indeterminate(httpStatus) {
248
+ status = 'indeterminate';
249
+ error = new AuthError(httpStatus
250
+ ? `The session check failed (${httpStatus}); the sign-in state is unknown.`
251
+ : 'The session check could not reach the server; the sign-in state is unknown.', httpStatus, 'SESSION_CHECK_FAILED');
252
+ emit();
253
+ }
254
+ function superseded() {
255
+ return new AuthError('A newer sign-in or sign-out replaced this request.', 0, 'AUTH_SUPERSEDED');
256
+ }
257
+ function timedOut() {
258
+ return new AuthError('The auth request timed out; the server may still have completed it.', 0, 'AUTH_TIMEOUT');
259
+ }
260
+ /** Run a cookie-changing call after every earlier one has settled. The
261
+ * deadline covers the whole call (request, body, follow-up /me): at
262
+ * mutationTimeoutMs the call is aborted and rejected so the queue moves on,
263
+ * and the task must write nothing once `signal.aborted` is true. */
264
+ function enqueue(task) {
265
+ pendingMutations++;
266
+ const run = mutations
267
+ .then(() => new Promise((resolve, reject) => {
268
+ const controller = new AbortController();
269
+ const timer = setTimeout(() => {
270
+ controller.abort();
271
+ reject(timedOut());
272
+ }, mutationTimeoutMs);
273
+ const execute = () => new Promise((done, fail) => {
274
+ const abort = () => fail(timedOut());
275
+ if (controller.signal.aborted) {
276
+ abort();
277
+ return;
278
+ }
279
+ controller.signal.addEventListener('abort', abort, { once: true });
280
+ task(controller.signal).then(done, fail).finally(() => controller.signal.removeEventListener('abort', abort));
281
+ });
282
+ const work = typeof navigator !== 'undefined' && navigator.locks
283
+ ? navigator.locks.request(refreshStampKey, { signal: controller.signal }, execute) : execute();
284
+ work
285
+ .then(resolve, (e) => reject(controller.signal.aborted ? timedOut() : e))
286
+ .finally(() => clearTimeout(timer));
287
+ }))
288
+ .finally(() => {
289
+ pendingMutations--;
290
+ });
291
+ mutations = run.catch(() => undefined);
292
+ return run;
293
+ }
294
+ // Cross-tab sync: `storage` fires only in OTHER tabs/windows of the same
295
+ // origin. A rotated header pair for the same identity is adopted (the
296
+ // multi-tab refresh footgun this package exists to kill). A different
297
+ // identity is NOT trusted: status goes to 'checking' and /me re-checks.
298
+ // key === null means storage.clear().
299
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
300
+ window.addEventListener('storage', (e) => {
301
+ if (e.key !== null && e.key !== key && e.key !== userKey && e.key !== signOutKey)
302
+ return;
303
+ const intent = readSignOutIntent();
304
+ if (intent) {
305
+ generation++;
306
+ writeSession(null);
307
+ owedSignOut = intent.startsWith('pending:')
308
+ ? { session: null, error: new AuthError('Sign-out has not been confirmed by the server.', 0, 'SIGN_OUT_UNCONFIRMED') } : null;
309
+ settle('signed-out', null);
310
+ return;
311
+ }
312
+ const nextSession = loadSession();
313
+ const nextUser = nextSession || preferCookie ? loadUser() : null;
314
+ const identityChanged = (nextUser?.id ?? null) !== (user?.id ?? null) || !nextSession !== !session;
315
+ if (!identityChanged) {
316
+ if (nextSession?.accessToken !== session?.accessToken || nextSession?.refreshToken !== session?.refreshToken) {
317
+ session = nextSession;
318
+ accessExpiresAt = nextSession ? tokenExpiry(nextSession.accessToken) : 0;
319
+ scheduleRefresh();
320
+ emit();
321
+ }
322
+ return;
323
+ }
324
+ generation++;
325
+ session = nextSession;
326
+ user = nextUser;
327
+ status = 'checking';
328
+ error = null;
329
+ emit();
330
+ void getUser();
331
+ });
332
+ }
333
+ // Standard short access-token refresh. Timers are an optimization: a resumed
334
+ // client also refreshes before sending protected work. No password is involved.
335
+ function scheduleRefresh(delay) {
336
+ if (refreshTimer !== undefined)
337
+ clearTimeout(refreshTimer);
338
+ if (status !== 'authenticated' || refreshUncertain || readSignOutIntent())
339
+ return;
340
+ // An unknown expiry is not an expired one: refreshing at once raced the page change that follows
341
+ // a sign-in (pfb_01e440702f31). The next session check learns the expiry (X-Sw-Access-Expires).
342
+ if (delay === undefined) {
343
+ if (!(accessExpiresAt > 0)) {
344
+ nextRefreshAt = Number.POSITIVE_INFINITY;
345
+ return;
346
+ }
347
+ delay = Math.max(0, accessExpiresAt - Date.now() - 10000 - Math.random() * 3000);
348
+ }
349
+ nextRefreshAt = Date.now() + delay;
350
+ if (typeof window === 'undefined')
351
+ return;
352
+ refreshTimer = setTimeout(() => { void refreshSession(true).catch(() => undefined); }, delay);
353
+ }
354
+ function refreshSession(background = false) {
355
+ if (readSignOutIntent())
356
+ return Promise.resolve();
357
+ if (refreshFlight)
358
+ return refreshFlight;
359
+ if (refreshUncertain)
360
+ return Promise.reject(new AuthError('Refresh outcome is unknown. Sign in again to establish a new session.', 0, 'REFRESH_OUTCOME_UNKNOWN'));
361
+ const started = generation;
362
+ const run = async (signal) => {
363
+ if (started !== generation || status === 'signed-out' || readSignOutIntent())
364
+ return;
365
+ // Another browser tab may have refreshed while this one awaited the lock.
366
+ let sharedAt = 0;
367
+ try {
368
+ sharedAt = Number(storage.getItem(refreshStampKey));
369
+ }
370
+ catch { /* storage can be unavailable */ }
371
+ if (Number.isFinite(sharedAt) && sharedAt > Date.now()) {
372
+ if (!preferCookie) {
373
+ session = loadSession();
374
+ accessExpiresAt = session ? tokenExpiry(session.accessToken) : 0;
375
+ }
376
+ scheduleRefresh(Math.max(1000, sharedAt - Date.now()));
377
+ return;
378
+ }
379
+ beginRefresh();
380
+ const headers = { 'X-Sw-Auth-Mode': preferCookie ? 'cookie' : 'header' };
381
+ if (session) {
382
+ headers.Authorization = 'Bearer ' + session.accessToken;
383
+ headers[RIDE_ALONG_HEADER] = session.refreshToken;
384
+ }
385
+ // Cookie refresh must not outlive its document and overwrite cookies from
386
+ // a newer sign-in. Interrupted rotations retain the existing unknown-outcome
387
+ // marker; they are reconciled without replaying a possibly consumed token.
388
+ const res = await fetch(url('/refresh'), { method: 'POST', credentials: 'include', headers, signal, keepalive: !preferCookie });
389
+ if (started !== generation || signal.aborted || readSignOutIntent())
390
+ return;
391
+ if (res.status === 401 || res.status === 403) {
392
+ confirmRefresh();
393
+ writeSession(null);
394
+ settle('signed-out', null);
395
+ return;
396
+ }
397
+ if (!res.ok)
398
+ throw new AuthError('Session refresh unavailable.', res.status);
399
+ const body = await res.json();
400
+ if (started !== generation || signal.aborted || readSignOutIntent())
401
+ return;
402
+ if (!preferCookie) {
403
+ const tokens = pickTokens(body);
404
+ if (!tokens)
405
+ throw new AuthError('Refresh did not return credentials.', 502);
406
+ writeSession({ accessToken: tokens.access, refreshToken: tokens.refresh });
407
+ }
408
+ if (body.user)
409
+ writeUser(body.user);
410
+ accessExpiresAt = body.expires_at ?? (session ? tokenExpiry(session.accessToken) : 0);
411
+ if (!Number.isFinite(accessExpiresAt) || accessExpiresAt <= Date.now())
412
+ throw new AuthError('Refresh returned an expired access token.', 502);
413
+ const delay = Math.max(1000, accessExpiresAt - Date.now() - 10000 - Math.random() * 3000);
414
+ confirmRefresh();
415
+ try {
416
+ storage.setItem(refreshStampKey, String(Date.now() + delay));
417
+ }
418
+ catch { /* memory still works */ }
419
+ scheduleRefresh(delay);
420
+ };
421
+ // Web Locks serialize refresh across same-origin tabs; the existing mutation
422
+ // queue serializes it against sign-in/out in this client.
423
+ refreshInBackground = background;
424
+ refreshFlight = enqueue(run).catch((e) => {
425
+ if (started === generation) {
426
+ refreshUncertain = true;
427
+ indeterminate(0);
428
+ if (refreshTimer !== undefined)
429
+ clearTimeout(refreshTimer);
430
+ }
431
+ throw e;
432
+ }).finally(() => { refreshFlight = null; refreshInBackground = false; });
433
+ return refreshFlight;
434
+ }
435
+ if (typeof window !== 'undefined') {
436
+ window.addEventListener('focus', () => { if (status === 'authenticated' && Date.now() >= nextRefreshAt)
437
+ void refreshSession(true).catch(() => undefined); });
438
+ }
439
+ // A background refresh starts while the held access token is still valid (10-13 s before
440
+ // expiry). A read sent meanwhile need not wait for it: it goes out on the still-valid token
441
+ // with X-No-Auto-Refresh (and no ride-along refresh token), so the server can never rotate the
442
+ // refresh credential the background refresh is already rotating, and the response carries no
443
+ // pair that could overwrite the new one. If the token is rejected anyway (clock skew), the read
444
+ // waits for the refresh to settle and is sent ONCE more the normal way; it is never treated as
445
+ // a sign-out while the refresh is pending. Only GET/HEAD qualify: a mutation still waits, so a
446
+ // mutation is never sent twice. A demand refresh (token expired or about to be) still blocks.
447
+ const CONCURRENT_READ_MIN_MS = 5000;
448
+ const authFetch = (input, init = {}) => send(input, init, true);
449
+ const sendDuringRefresh = (input, init) => {
450
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
451
+ headers.set('X-No-Auto-Refresh', '1');
452
+ headers.delete(RIDE_ALONG_HEADER);
453
+ const target = input instanceof Request ? input.clone() : input;
454
+ // Cookie transport: the browser attaches the cookies; the opt-out keeps the server from
455
+ // rotating them in-band, so no Set-Cookie can race the background refresh.
456
+ if (preferCookie && !session)
457
+ return fetch(target, { credentials: 'include', ...init, headers });
458
+ if (session)
459
+ headers.set('Authorization', `Bearer ${session.accessToken}`);
460
+ return fetch(target, { ...init, headers });
461
+ };
462
+ const send = async (input, init = {}, mayOverlapRefresh) => {
463
+ if (readSignOutIntent()) {
464
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
465
+ headers.delete('Authorization');
466
+ headers.delete(RIDE_ALONG_HEADER);
467
+ return fetch(input, { ...init, headers, credentials: 'omit' });
468
+ }
469
+ if (refreshUncertain) {
470
+ if (status !== 'authenticated')
471
+ throw new AuthError('Refresh outcome is unknown. Sign in again.', 0, 'REFRESH_OUTCOME_UNKNOWN');
472
+ // Reconciled: the held access token still answers, but the refresh credential behind it may
473
+ // have rotated, so it is used as is and never rotated. When it runs out, re-check the session.
474
+ const res = await sendDuringRefresh(input, init);
475
+ if (res.status === 401)
476
+ void getUser();
477
+ return res;
478
+ }
479
+ const method = (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
480
+ const flight = refreshFlight;
481
+ const overlap = mayOverlapRefresh && flight !== null && refreshInBackground
482
+ && (method === 'GET' || method === 'HEAD')
483
+ && Number.isFinite(accessExpiresAt) && accessExpiresAt - Date.now() >= CONCURRENT_READ_MIN_MS;
484
+ if (flight && !overlap)
485
+ await flight;
486
+ else if (!flight && pendingMutations === 0 && status === 'authenticated' && Date.now() >= nextRefreshAt)
487
+ await refreshSession();
488
+ if (readSignOutIntent())
489
+ return send(input, init, false);
490
+ const started = generation;
491
+ if (overlap && flight) {
492
+ const res = await sendDuringRefresh(input, init);
493
+ if (res.status !== 401)
494
+ return res;
495
+ await flight.catch(() => undefined);
496
+ if (started !== generation)
497
+ return res;
498
+ return send(input, init, false);
499
+ }
500
+ // Cookie path: no held tokens — the browser attaches the httpOnly pair
501
+ // and the server refreshes it in-band (re-issued via Set-Cookie). Taken
502
+ // only when no header session exists, so an adopted pre-0.2.0 session
503
+ // keeps its header transport untouched.
504
+ if (preferCookie && !session) {
505
+ // NETWORK BLIP — if fetch throws, the request never reached the server;
506
+ // the throw propagates and the cached user stays. A fetch failure is
507
+ // NOT a 401.
508
+ const res = await fetch(input, { credentials: 'include', ...init });
509
+ // The server had the refresh cookie and still rejected: the session is
510
+ // genuinely dead — unless a newer sign-in/out happened meanwhile.
511
+ if (res.status === 401 && started === generation && status !== 'signed-out')
512
+ settle('signed-out', null);
513
+ return res;
514
+ }
515
+ const attempt = async (sess) => {
516
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
517
+ if (sess) {
518
+ headers.set('Authorization', `Bearer ${sess.accessToken}`);
519
+ headers.set(RIDE_ALONG_HEADER, sess.refreshToken);
520
+ }
521
+ // Clone Request inputs so a body can survive the one stale-session
522
+ // retry below (a consumed body throws on re-send).
523
+ const target = input instanceof Request ? input.clone() : input;
524
+ // NETWORK BLIP — if fetch throws, the request never reached the
525
+ // server. The throw propagates untouched; the session stays.
526
+ return fetch(target, { ...init, headers });
527
+ };
528
+ // Rotation: only when BOTH new tokens are present, and only for the
529
+ // identity this request started under. Atomic swap.
530
+ const applyRotation = (res) => {
531
+ const newAccess = res.headers.get(ACCESS_ROTATE_HEADER);
532
+ const newRefresh = res.headers.get(REFRESH_ROTATE_HEADER);
533
+ if (newAccess && newRefresh && started === generation) {
534
+ setSession({ accessToken: newAccess, refreshToken: newRefresh });
535
+ }
536
+ };
537
+ const used = session;
538
+ let res = await attempt(used);
539
+ applyRotation(res);
540
+ // 401 with a since-rotated pair: another tab may have refreshed while
541
+ // this request was in flight. Re-read storage; if a DIFFERENT pair for
542
+ // the SAME identity is there, retry ONCE with it. Never retry under a
543
+ // newer identity.
544
+ if (res.status === 401 && used && started === generation) {
545
+ const stored = loadSession();
546
+ if (stored && (stored.accessToken !== used.accessToken || stored.refreshToken !== used.refreshToken)) {
547
+ setSession(stored);
548
+ res = await attempt(stored);
549
+ applyRotation(res);
550
+ }
551
+ }
552
+ // A 401 on the freshest pair means refresh already failed server-side
553
+ // (it had the ride-along refresh token and still couldn't mint a
554
+ // session). The session is genuinely dead — clear it, if it is still the
555
+ // identity this request started under. Any other status leaves it intact.
556
+ if (res.status === 401 && session && started === generation) {
557
+ writeSession(null);
558
+ settle('signed-out', null);
559
+ }
560
+ return res;
561
+ };
562
+ function url(path) {
563
+ return `${baseUrl}${authPath}${path}`;
564
+ }
565
+ function pickTokens(body) {
566
+ const d = body.data ?? body;
567
+ const access = d.token ?? d.access_token;
568
+ const refresh = d.refresh_token;
569
+ if (!access || !refresh)
570
+ return null;
571
+ return { access, refresh, user: d.user ?? null };
572
+ }
573
+ /** Reads `{ user }` / `{ data: { user } }`. undefined = unreadable answer. */
574
+ function readUser(body) {
575
+ if (!body || typeof body !== 'object')
576
+ return undefined;
577
+ const b = body;
578
+ const value = 'user' in b ? b.user : b.data && typeof b.data === 'object' && 'user' in b.data ? b.data.user : undefined;
579
+ if (value === null)
580
+ return null;
581
+ if (isAuthUser(value))
582
+ return value;
583
+ return undefined;
584
+ }
585
+ function postForSession(path, payload, completingMfa = false) {
586
+ if (!completingMfa)
587
+ pendingMfa = null;
588
+ // Intent: from this call on, earlier checks and updates no longer apply.
589
+ const mine = ++generation;
590
+ try {
591
+ storage.removeItem(refreshStampKey);
592
+ }
593
+ catch { /* optional cross-tab coordination */ }
594
+ const run = enqueue(async (signal) => {
595
+ // Before any write: a timed-out or superseded call changes nothing.
596
+ const stillCurrent = () => {
597
+ if (signal.aborted)
598
+ throw timedOut();
599
+ if (generation !== mine)
600
+ throw superseded();
601
+ };
602
+ try {
603
+ storage.removeItem(refreshStampKey);
604
+ }
605
+ catch { /* optional cross-tab coordination */ }
606
+ const headers = { 'Content-Type': 'application/json' };
607
+ // Header mode must ASK for tokens: a handler answers a request without
608
+ // the hint with a cookie session only (pfb_e1254f754222).
609
+ headers[MODE_HINT_HEADER] = preferCookie ? 'cookie' : 'header';
610
+ const res = await fetch(url(path), {
611
+ method: 'POST',
612
+ headers,
613
+ body: JSON.stringify(payload ?? {}),
614
+ signal,
615
+ ...(preferCookie ? { credentials: 'include' } : {}),
616
+ });
617
+ const body = (await res.json().catch(() => ({})));
618
+ stillCurrent();
619
+ const challenge = mfaChallenge(body);
620
+ if (challenge) {
621
+ pendingMfa = challenge.mfa_token;
622
+ if (status === 'checking')
623
+ settle('signed-out', null);
624
+ throw new AuthError('Enter an authenticator or backup code to finish signing in.', res.status, 'MFA_REQUIRED');
625
+ }
626
+ if (!res.ok)
627
+ throw new AuthError(body.message || body.error || `Request failed (${res.status})`, res.status);
628
+ // Cookie handshake confirmed: the backend set the httpOnly pair and the
629
+ // browser owns the session — no tokens in the body, nothing to store.
630
+ // This is also the transparent header→cookie migration point: any legacy
631
+ // localStorage pair from a pre-0.2.0 install is purged here.
632
+ const d = body.data ?? body;
633
+ confirmRefresh();
634
+ refreshUncertain = false;
635
+ accessExpiresAt = Number(d.expires_at) || 0;
636
+ if (preferCookie && (d.cookie_session === true || body.cookie_session === true)) {
637
+ if (session) {
638
+ writeSession(null);
639
+ accessExpiresAt = Number(d.expires_at) || 0;
640
+ }
641
+ if (d.user) {
642
+ if (!isAuthUser(d.user))
643
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
644
+ owedSignOut = null; // this confirmed identity replaces the owed session
645
+ writeSignOutIntent(null);
646
+ settle('authenticated', d.user);
647
+ }
648
+ else
649
+ await checkNow(mine, signal, true);
650
+ stillCurrent();
651
+ if (!isAuthUser(user))
652
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
653
+ pendingMfa = null;
654
+ return user;
655
+ }
656
+ // Cookie mode was requested and the server did not confirm it: the
657
+ // handler omitted cookie mode (a pasted or pre-0.2.0 handler). Storing the
658
+ // tokens it returned would leave the session where page scripts can read
659
+ // it, so fail loudly and store nothing (pfb_961179f4970e).
660
+ if (preferCookie) {
661
+ throw new AuthError(`Sign-in reached ${url(path)} but the server did not confirm a cookie session, so nothing was stored. ` +
662
+ `The handler there does not support cookie mode: mount the SDK's handler ` +
663
+ `(export { somewhereAuth as default } from '@somewhere-cloud/sdk/server'), or make yours set the ` +
664
+ `session cookies and reply { user, cookie_session: true } when the request has X-Sw-Auth-Mode: cookie.`, res.status, 'COOKIE_SESSION_NOT_CONFIRMED');
665
+ }
666
+ // Header mode (explicit, or the non-browser default): the caller asked
667
+ // for tokens in storage.
668
+ const tokens = pickTokens(body);
669
+ if (!tokens)
670
+ throw new AuthError('Auth response did not include a session.', res.status);
671
+ if (tokens.user && !isAuthUser(tokens.user))
672
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
673
+ writeSession({ accessToken: tokens.access, refreshToken: tokens.refresh });
674
+ accessExpiresAt = Number(d.expires_at) || accessExpiresAt;
675
+ owedSignOut = null;
676
+ if (tokens.user)
677
+ settle('authenticated', tokens.user);
678
+ else
679
+ await checkNow(mine, signal);
680
+ stillCurrent();
681
+ if (!isAuthUser(user))
682
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
683
+ pendingMfa = null;
684
+ return user;
685
+ });
686
+ // Any failure of the current call (server refusal, network, timeout) that
687
+ // invalidated the first check: ask /me so the state leaves 'checking'.
688
+ // A failure after a newer sign-in/out changes nothing.
689
+ return run.catch((e) => {
690
+ if (generation === mine && status === 'checking' && !(e instanceof AuthError && e.code === 'MFA_REQUIRED'))
691
+ void getUser();
692
+ throw e;
693
+ });
694
+ }
695
+ /** One /me round-trip. Writes state only if `started` is still current and
696
+ * the caller's `signal` (a queued call's deadline) has not fired. */
697
+ async function checkNow(started, signal, completingSignIn = false) {
698
+ const stale = () => started !== generation || signal?.aborted === true || (!completingSignIn && !!readSignOutIntent());
699
+ if (stale())
700
+ return user;
701
+ if (refreshUncertain)
702
+ return reconcileUnknownRefresh(started, signal);
703
+ const cookie = !session && preferCookie;
704
+ if (!cookie && !session) {
705
+ if (!stale())
706
+ settle('signed-out', null);
707
+ return null;
708
+ }
709
+ let res;
710
+ let orphaned = false;
711
+ try {
712
+ // Cookie mode: the browser owns the session, so always probe /me with
713
+ // credentials — even with no cached user (the cookie can outlive a
714
+ // cleared cache). Header mode rides authFetch (refresh + rotation).
715
+ const cookieCheck = async (checkSignal) => {
716
+ // /me may rotate an expired cookie. It uses the same cross-tab lock and
717
+ // durable pending marker as explicit refresh, including page restore.
718
+ let sharedAt = 0;
719
+ try {
720
+ sharedAt = Number(storage.getItem(refreshStampKey));
721
+ }
722
+ catch { /* optional */ }
723
+ const mayRotate = accessExpiresAt <= Date.now() && sharedAt <= Date.now();
724
+ if (mayRotate && refreshOrphaned()) {
725
+ orphaned = true;
726
+ return probeWithoutRotation(checkSignal);
727
+ }
728
+ const marker = mayRotate ? beginRefresh() : undefined;
729
+ try {
730
+ const reply = await fetch(url('/me'), { method: 'GET', credentials: 'include', signal: checkSignal });
731
+ if (checkSignal.aborted)
732
+ return reply;
733
+ if (mayRotate && (reply.ok || reply.status === 401 || reply.status === 403))
734
+ confirmRefresh(marker);
735
+ if (started !== generation)
736
+ return reply;
737
+ if (mayRotate && !reply.ok && reply.status !== 401 && reply.status !== 403)
738
+ refreshUncertain = true;
739
+ const expiry = Number(reply.headers.get('X-Sw-Access-Expires'));
740
+ if (Number.isFinite(expiry) && expiry > Date.now())
741
+ accessExpiresAt = expiry;
742
+ return reply;
743
+ }
744
+ catch (e) {
745
+ if (mayRotate && !checkSignal.aborted && started === generation)
746
+ refreshUncertain = true;
747
+ throw e;
748
+ }
749
+ };
750
+ res = cookie
751
+ ? await (signal ? cookieCheck(signal) : enqueue(cookieCheck))
752
+ : await authFetch(url('/me'), { method: 'GET', signal });
753
+ }
754
+ catch {
755
+ // A refresh with an unknown outcome surfaced here: reconcile it now rather than on a later call.
756
+ if (!stale() && refreshUncertain)
757
+ return reconcileUnknownRefresh(started, signal);
758
+ // Network blip: never a logout. Keep the last-known user, unverified.
759
+ if (!stale())
760
+ indeterminate(0);
761
+ return user;
762
+ }
763
+ if (stale())
764
+ return user;
765
+ if (orphaned)
766
+ return settleReconciled(res, started, signal);
767
+ // 401 or an explicit { user: null } is DEFINITIVE signed-out, which also
768
+ // settles an owed sign-out: the server holds no session for this browser.
769
+ if (res.status === 401) {
770
+ owedSignOut = null;
771
+ settle('signed-out', null);
772
+ return null;
773
+ }
774
+ if (!res.ok) {
775
+ indeterminate(res.status);
776
+ return user;
777
+ }
778
+ const body = await res.json().catch(() => undefined);
779
+ if (stale())
780
+ return user;
781
+ const u = readUser(body);
782
+ if (u === undefined) {
783
+ indeterminate(res.status);
784
+ return user;
785
+ }
786
+ if (completingSignIn && !u)
787
+ throw new AuthError('Auth response did not include a valid user.', 502, 'INVALID_AUTH_RESPONSE');
788
+ // Only an explicit successful sign-in can replace durable logout intent.
789
+ owedSignOut = null;
790
+ if (completingSignIn && u)
791
+ writeSignOutIntent(null);
792
+ settle(u ? 'authenticated' : 'signed-out', u);
793
+ return u;
794
+ }
795
+ /** /me that can never rotate: no ride-along refresh token, and X-No-Auto-Refresh, so the server
796
+ * answers from the held access credential alone. */
797
+ function probeWithoutRotation(probeSignal) {
798
+ const headers = { 'X-No-Auto-Refresh': '1' };
799
+ if (session)
800
+ headers.Authorization = 'Bearer ' + session.accessToken;
801
+ return fetch(url('/me'), { method: 'GET', credentials: 'include', headers, signal: probeSignal });
802
+ }
803
+ /** An earlier refresh's outcome is unknown: it may have rotated the refresh credential, and
804
+ * replaying a rotated one past the server's grace revokes every session of the user. So the state
805
+ * is reconciled without rotating: a still-valid access credential keeps the user signed in (used,
806
+ * never rotated, until it runs out); one that no longer answers cannot be confirmed safely, so the
807
+ * dead local marker and session are cleared and a sign-in is asked for. A failed check stays
808
+ * indeterminate and the next getUser() checks again, never a permanent no-op. */
809
+ async function reconcileUnknownRefresh(started, signal) {
810
+ const stale = () => started !== generation || signal?.aborted === true || !!readSignOutIntent();
811
+ if (stale())
812
+ return user;
813
+ refreshUncertain = true;
814
+ let res;
815
+ try {
816
+ res = await (signal ? probeWithoutRotation(signal) : enqueue(probeWithoutRotation));
817
+ }
818
+ catch {
819
+ if (!stale())
820
+ indeterminate(0);
821
+ return user;
822
+ }
823
+ if (stale())
824
+ return user;
825
+ return settleReconciled(res, started, signal);
826
+ }
827
+ async function settleReconciled(res, started, signal) {
828
+ const stale = () => started !== generation || signal?.aborted === true || !!readSignOutIntent();
829
+ if (stale())
830
+ return user;
831
+ refreshUncertain = true;
832
+ if (res.status !== 401 && !res.ok) {
833
+ indeterminate(res.status);
834
+ return user;
835
+ }
836
+ const u = res.status === 401 ? null : readUser(await res.json().catch(() => undefined));
837
+ if (stale())
838
+ return user;
839
+ if (u === undefined) {
840
+ indeterminate(res.status);
841
+ return user;
842
+ }
843
+ if (u) {
844
+ const expiry = Number(res.headers.get('X-Sw-Access-Expires'));
845
+ if (Number.isFinite(expiry) && expiry > Date.now())
846
+ accessExpiresAt = expiry;
847
+ if (refreshTimer !== undefined)
848
+ clearTimeout(refreshTimer);
849
+ writeUser(u);
850
+ status = 'authenticated';
851
+ error = null;
852
+ emit();
853
+ return u;
854
+ }
855
+ confirmRefresh();
856
+ refreshUncertain = false;
857
+ writeSession(null);
858
+ owedSignOut = null;
859
+ writeUser(null);
860
+ status = 'signed-out';
861
+ error = new AuthError('Your sign-in could not be confirmed. Sign in again.', 401, 'SIGN_IN_REQUIRED');
862
+ emit();
863
+ return null;
864
+ }
865
+ /** Validate the session against /me. Runs after queued sign-ins/outs and is
866
+ * shared by concurrent callers within one generation. */
867
+ function getUser() {
868
+ if (check && check.generation === generation)
869
+ return check.promise;
870
+ const started = generation;
871
+ const promise = mutations
872
+ .then(() => {
873
+ if (started !== generation)
874
+ return user;
875
+ const intent = readSignOutIntent();
876
+ if (intent) {
877
+ if (intent.startsWith('pending:'))
878
+ return client.signOut().then(() => null);
879
+ settle('signed-out', null);
880
+ return null;
881
+ }
882
+ return checkNow(started);
883
+ })
884
+ .finally(() => {
885
+ if (check?.promise === promise)
886
+ check = null;
887
+ });
888
+ check = { generation: started, promise };
889
+ return promise;
890
+ }
891
+ const client = {
892
+ fetch: authFetch,
893
+ signUp: (i) => postForSession('/signup', { email: i.email, password: i.password, display_name: i.displayName }),
894
+ signIn: (i) => postForSession('/login', { email: i.email, password: i.password }),
895
+ sendMagicLink: async (i) => {
896
+ const res = await fetch(url('/magic-link'), {
897
+ method: 'POST',
898
+ headers: { 'Content-Type': 'application/json' },
899
+ body: JSON.stringify({ email: i.email, redirect_uri: i.redirectUri }),
900
+ });
901
+ if (!res.ok) {
902
+ const b = (await res.json().catch(() => ({})));
903
+ throw new AuthError(b.message || b.error || `Could not send magic link (${res.status})`, res.status);
904
+ }
905
+ },
906
+ verifyMagicLink: (i) => postForSession('/magic-link/verify', { token: i.token }),
907
+ emailVerified: async () => {
908
+ const res = await authFetch(url('/verify-email'), { method: 'GET' });
909
+ const b = (await res.json().catch(() => ({})));
910
+ if (!res.ok)
911
+ throw new AuthError(b.message || b.error || `Could not check the email address (${res.status})`, res.status);
912
+ return b.email_verified === true;
913
+ },
914
+ requestEmailVerification: async () => {
915
+ const res = await authFetch(url('/request-email-verification'), {
916
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
917
+ });
918
+ if (!res.ok) {
919
+ const b = (await res.json().catch(() => ({})));
920
+ throw new AuthError(b.message || b.error || `Could not send the verification code (${res.status})`, res.status);
921
+ }
922
+ },
923
+ verifyEmail: async (i) => {
924
+ const res = await authFetch(url('/verify-email'), {
925
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: i.code }),
926
+ });
927
+ if (!res.ok) {
928
+ const b = (await res.json().catch(() => ({})));
929
+ throw new AuthError(b.message || b.error || `Could not verify the code (${res.status})`, res.status);
930
+ }
931
+ },
932
+ googleSignInUrl: async (i) => {
933
+ const res = await fetch(url(`/google-url${i?.redirectUri ? `?redirect_uri=${encodeURIComponent(i.redirectUri)}` : ''}`));
934
+ const b = (await res.json().catch(() => ({})));
935
+ const u = b.url ?? b.data?.url;
936
+ if (!u)
937
+ throw new AuthError('Could not get the Google sign-in URL.', res.status);
938
+ return u;
939
+ },
940
+ completeGoogleSignIn: (i) => postForSession('/google', { code: i.code, ...oauthAttempt(i) }),
941
+ githubSignInUrl: async (i) => {
942
+ const res = await fetch(url(`/github-url${i?.redirectUri ? `?redirect_uri=${encodeURIComponent(i.redirectUri)}` : ''}`));
943
+ const b = (await res.json().catch(() => ({})));
944
+ const u = b.url ?? b.data?.url;
945
+ if (!u)
946
+ throw new AuthError('Could not get the GitHub sign-in URL.', res.status);
947
+ return u;
948
+ },
949
+ completeGithubSignIn: (i) => postForSession('/github', { code: i.code, ...oauthAttempt(i) }),
950
+ discordSignInUrl: async (i) => {
951
+ const res = await fetch(url(`/discord-url${i?.redirectUri ? `?redirect_uri=${encodeURIComponent(i.redirectUri)}` : ''}`));
952
+ const b = (await res.json().catch(() => ({})));
953
+ const u = b.url ?? b.data?.url;
954
+ if (!u)
955
+ throw new AuthError('Could not get the Discord sign-in URL.', res.status);
956
+ return u;
957
+ },
958
+ completeDiscordSignIn: (i) => postForSession('/discord', { code: i.code, ...oauthAttempt(i) }),
959
+ completeMfa: (input) => {
960
+ if (!pendingMfa)
961
+ return Promise.reject(new AuthError('There is no pending MFA sign-in. Start signing in again.', 400, 'MFA_CHALLENGE_REQUIRED'));
962
+ return postForSession('/mfa/challenge', { mfa_token: pendingMfa, code: input.code }, true);
963
+ },
964
+ signOut: () => {
965
+ pendingMfa = null;
966
+ const existingIntent = readSignOutIntent();
967
+ // An owed sign-out is retried with the header session it was for.
968
+ const had = session ?? owedSignOut?.session ?? null;
969
+ // Something to end server-side: an owed sign-out, a header session, a
970
+ // known or pending cookie identity, or a sign-in queued ahead of this call.
971
+ const serverWork = existingIntent?.startsWith('pending:') || owedSignOut !== null || !!had
972
+ || (preferCookie && (!!user || status !== 'signed-out' || pendingMutations > 0));
973
+ // Invalidate earlier checks/rotations and drop credentials immediately.
974
+ // Retain the last-known user until settlement: a user-null UI can trigger
975
+ // navigation before this queued request has even started.
976
+ const mine = ++generation;
977
+ writeSession(null);
978
+ // Pending until /logout is answered: consumers show "signing out", not
979
+ // "signed out", while the server may still accept the session.
980
+ if (!serverWork) {
981
+ settle('signed-out', null);
982
+ return Promise.resolve();
983
+ }
984
+ const intent = existingIntent?.startsWith('pending:') ? existingIntent : `pending:${Date.now()}:${Math.random()}`;
985
+ if (preferCookie)
986
+ writeSignOutIntent(intent);
987
+ signOutsInFlight++;
988
+ status = 'checking';
989
+ scheduleRefresh();
990
+ emit();
991
+ const unconfirmed = (failure) => {
992
+ // Owed only for this call's generation: a newer sign-in replaced the
993
+ // session, and a newer signOut() retries it itself.
994
+ if (generation !== mine)
995
+ return;
996
+ owedSignOut = { session: had, error: failure };
997
+ error = failure;
998
+ emit();
999
+ };
1000
+ // Server-side revoke (and, in cookie mode, the Set-Cookie expirations
1001
+ // that actually end the browser session), after every earlier queued
1002
+ // call. Resolves either way; a failure stays owed (signOutUnconfirmed,
1003
+ // error 'SIGN_OUT_UNCONFIRMED') and the next signOut() retries it.
1004
+ return enqueue(async (signal) => {
1005
+ // A new sign-in in another tab may have replaced this queued logout.
1006
+ if (preferCookie && readSignOutIntent() !== intent)
1007
+ return;
1008
+ let res;
1009
+ try {
1010
+ res = await fetch(url('/logout'), {
1011
+ method: 'POST',
1012
+ // Cookie logout is resumed from durable intent after navigation.
1013
+ // Do not let the old document's late Set-Cookie erase a newer login.
1014
+ // Header revocation has no browser cookie response to race.
1015
+ keepalive: !preferCookie,
1016
+ // A header session is revoked by its refresh token, sent as the same
1017
+ // ride-along header authFetch uses; the access bearer alone revokes
1018
+ // nothing. `had` is captured at call time, so a retry resends the
1019
+ // credential of the session it is for, never a newer one.
1020
+ headers: had
1021
+ ? { 'Content-Type': 'application/json', Authorization: `Bearer ${had.accessToken}`, [RIDE_ALONG_HEADER]: had.refreshToken }
1022
+ : { 'Content-Type': 'application/json' },
1023
+ signal,
1024
+ ...(preferCookie ? { credentials: 'include' } : {}),
1025
+ });
1026
+ }
1027
+ catch {
1028
+ if (!signal.aborted)
1029
+ unconfirmed(new AuthError('Sign-out could not reach the server; the session may still be active.', 0, 'SIGN_OUT_UNCONFIRMED'));
1030
+ return;
1031
+ }
1032
+ if (signal.aborted)
1033
+ return;
1034
+ if (!res.ok) {
1035
+ unconfirmed(new AuthError(`The server did not confirm sign-out (${res.status}).`, res.status, 'SIGN_OUT_UNCONFIRMED'));
1036
+ return;
1037
+ }
1038
+ // /logout's finite JSON response should finish being read. Header
1039
+ // success already confirms logout; body latency/failure must not hold
1040
+ // the mutation queue or change that confirmation.
1041
+ if (res.body !== null && res.headers.get('Content-Type')?.split(';')[0].trim().toLowerCase() === 'application/json') {
1042
+ void res.text().catch(() => { });
1043
+ }
1044
+ if (preferCookie && readSignOutIntent() === intent)
1045
+ writeSignOutIntent(intent.replace('pending:', 'confirmed:'));
1046
+ owedSignOut = null;
1047
+ if (error?.code === 'SIGN_OUT_UNCONFIRMED')
1048
+ error = null;
1049
+ emit();
1050
+ }).catch(() => {
1051
+ // Only the deadline rejects here.
1052
+ unconfirmed(new AuthError('Sign-out timed out; the session may still be active.', 0, 'SIGN_OUT_UNCONFIRMED'));
1053
+ }).finally(() => {
1054
+ signOutsInFlight--;
1055
+ if (generation === mine)
1056
+ settle('signed-out', null);
1057
+ else
1058
+ emit();
1059
+ });
1060
+ },
1061
+ getUser,
1062
+ getSession: () => session,
1063
+ getCachedUser: () => user,
1064
+ getState: snapshot,
1065
+ onChange: (listener) => {
1066
+ listeners.add(listener);
1067
+ listener(snapshot());
1068
+ return () => void listeners.delete(listener);
1069
+ },
1070
+ billing: {
1071
+ has: (_feature) => { throw new AuthError('Paid access is no longer part of sign-in identity. Check it in a server endpoint with await sw.billing.has(feature).', 0, 'BILLING_EXPLICIT_CHECK_REQUIRED'); },
1072
+ entitlements: () => { throw new AuthError('Paid access is no longer part of sign-in identity. Read it in a server endpoint with await sw.billing.entitlements().', 0, 'BILLING_EXPLICIT_CHECK_REQUIRED'); },
1073
+ plans: async () => {
1074
+ const res = await authFetch(url('/plans'), { method: 'GET' });
1075
+ if (!res.ok) {
1076
+ const b = (await res.json().catch(() => ({})));
1077
+ throw new AuthError(b.message || b.error || `Could not load plans (${res.status})`, res.status);
1078
+ }
1079
+ const body = (await res.json().catch(() => ({})));
1080
+ return body.plans ?? body.data?.plans ?? [];
1081
+ },
1082
+ subscribe: async (plan, opts) => {
1083
+ const here = typeof window !== 'undefined' ? window.location.href : undefined;
1084
+ const res = await authFetch(url('/billing/checkout'), {
1085
+ method: 'POST',
1086
+ headers: { 'Content-Type': 'application/json' },
1087
+ body: JSON.stringify({
1088
+ plan,
1089
+ success_url: opts?.successUrl ?? here,
1090
+ cancel_url: opts?.cancelUrl ?? here,
1091
+ }),
1092
+ });
1093
+ if (!res.ok) {
1094
+ const b = (await res.json().catch(() => ({})));
1095
+ throw new AuthError(b.message || b.error || `Could not start checkout (${res.status})`, res.status);
1096
+ }
1097
+ const body = (await res.json().catch(() => ({})));
1098
+ const u = body.url ?? body.data?.url;
1099
+ if (!u)
1100
+ throw new AuthError('Checkout did not return a URL.', res.status);
1101
+ if (opts?.redirect !== false && typeof window !== 'undefined')
1102
+ window.location.assign(u);
1103
+ return { url: u };
1104
+ },
1105
+ openBillingPortal: async (opts) => {
1106
+ const here = typeof window !== 'undefined' ? window.location.href : undefined;
1107
+ const res = await authFetch(url('/billing/portal'), {
1108
+ method: 'POST',
1109
+ headers: { 'Content-Type': 'application/json' },
1110
+ body: JSON.stringify({ return_url: opts?.returnUrl ?? here }),
1111
+ });
1112
+ if (!res.ok) {
1113
+ const b = (await res.json().catch(() => ({})));
1114
+ throw new AuthError(b.message || b.error || `Could not open the billing portal (${res.status})`, res.status);
1115
+ }
1116
+ const body = (await res.json().catch(() => ({})));
1117
+ const u = body.url ?? body.data?.url;
1118
+ if (!u)
1119
+ throw new AuthError('Billing portal did not return a URL.', res.status);
1120
+ if (opts?.redirect !== false && typeof window !== 'undefined')
1121
+ window.location.assign(u);
1122
+ return { url: u };
1123
+ },
1124
+ },
1125
+ };
1126
+ return client;
1127
+ }
1128
+ export class AuthError extends Error {
1129
+ constructor(message, status, code) {
1130
+ super(message);
1131
+ this.name = 'AuthError';
1132
+ this.status = status;
1133
+ if (code !== undefined)
1134
+ this.code = code;
1135
+ }
1136
+ }
1137
+ //# sourceMappingURL=client.js.map