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