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