@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.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/dist/cjs/auth/client.js +1059 -0
- package/dist/cjs/auth/client.js.map +1 -0
- package/dist/cjs/auth/continuation.js +27 -0
- package/dist/cjs/auth/continuation.js.map +1 -0
- package/dist/cjs/auth/index.js +21 -0
- package/dist/cjs/auth/index.js.map +1 -0
- package/dist/cjs/auth/react.js +295 -0
- package/dist/cjs/auth/react.js.map +1 -0
- package/dist/cjs/auth/server.js +368 -0
- package/dist/cjs/auth/server.js.map +1 -0
- package/dist/cjs/client.js +392 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/errors.js +16 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.cjs +215 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/query-cache.js +91 -0
- package/dist/cjs/query-cache.js.map +1 -0
- package/dist/cjs/resources/auth.js +875 -0
- package/dist/cjs/resources/auth.js.map +1 -0
- package/dist/cjs/resources/calls.js +42 -0
- package/dist/cjs/resources/calls.js.map +1 -0
- package/dist/cjs/resources/chat.js +82 -0
- package/dist/cjs/resources/chat.js.map +1 -0
- package/dist/cjs/resources/db.js +124 -0
- package/dist/cjs/resources/db.js.map +1 -0
- package/dist/cjs/resources/emails.js +39 -0
- package/dist/cjs/resources/emails.js.map +1 -0
- package/dist/cjs/resources/fs.js +141 -0
- package/dist/cjs/resources/fs.js.map +1 -0
- package/dist/cjs/resources/functions.js +145 -0
- package/dist/cjs/resources/functions.js.map +1 -0
- package/dist/cjs/resources/inbox.js +76 -0
- package/dist/cjs/resources/inbox.js.map +1 -0
- package/dist/cjs/resources/payments.js +68 -0
- package/dist/cjs/resources/payments.js.map +1 -0
- package/dist/cjs/resources/postgrest.js +391 -0
- package/dist/cjs/resources/postgrest.js.map +1 -0
- package/dist/cjs/resources/projects.js +71 -0
- package/dist/cjs/resources/projects.js.map +1 -0
- package/dist/cjs/resources/storage.js +191 -0
- package/dist/cjs/resources/storage.js.map +1 -0
- package/dist/cjs/resources/tasks.js +99 -0
- package/dist/cjs/resources/tasks.js.map +1 -0
- package/dist/cjs/resources/video.js +50 -0
- package/dist/cjs/resources/video.js.map +1 -0
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/esm/auth/client.js +1054 -0
- package/dist/esm/auth/client.js.map +1 -0
- package/dist/esm/auth/continuation.js +22 -0
- package/dist/esm/auth/continuation.js.map +1 -0
- package/dist/esm/auth/index.js +5 -0
- package/dist/esm/auth/index.js.map +1 -0
- package/dist/esm/auth/react.js +279 -0
- package/dist/esm/auth/react.js.map +1 -0
- package/dist/esm/auth/server.js +365 -0
- package/dist/esm/auth/server.js.map +1 -0
- package/dist/esm/client.js +388 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/errors.js +12 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.js +188 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/query-cache.js +87 -0
- package/dist/esm/query-cache.js.map +1 -0
- package/dist/esm/resources/auth.js +871 -0
- package/dist/esm/resources/auth.js.map +1 -0
- package/dist/esm/resources/calls.js +38 -0
- package/dist/esm/resources/calls.js.map +1 -0
- package/dist/esm/resources/chat.js +77 -0
- package/dist/esm/resources/chat.js.map +1 -0
- package/dist/esm/resources/db.js +120 -0
- package/dist/esm/resources/db.js.map +1 -0
- package/dist/esm/resources/emails.js +35 -0
- package/dist/esm/resources/emails.js.map +1 -0
- package/dist/esm/resources/fs.js +137 -0
- package/dist/esm/resources/fs.js.map +1 -0
- package/dist/esm/resources/functions.js +141 -0
- package/dist/esm/resources/functions.js.map +1 -0
- package/dist/esm/resources/inbox.js +70 -0
- package/dist/esm/resources/inbox.js.map +1 -0
- package/dist/esm/resources/payments.js +64 -0
- package/dist/esm/resources/payments.js.map +1 -0
- package/dist/esm/resources/postgrest.js +386 -0
- package/dist/esm/resources/postgrest.js.map +1 -0
- package/dist/esm/resources/projects.js +67 -0
- package/dist/esm/resources/projects.js.map +1 -0
- package/dist/esm/resources/storage.js +186 -0
- package/dist/esm/resources/storage.js.map +1 -0
- package/dist/esm/resources/tasks.js +95 -0
- package/dist/esm/resources/tasks.js.map +1 -0
- package/dist/esm/resources/video.js +46 -0
- package/dist/esm/resources/video.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/types/auth/client.d.ts +272 -0
- package/dist/types/auth/client.d.ts.map +1 -0
- package/dist/types/auth/continuation.d.ts +10 -0
- package/dist/types/auth/continuation.d.ts.map +1 -0
- package/dist/types/auth/index.d.ts +2 -0
- package/dist/types/auth/index.d.ts.map +1 -0
- package/dist/types/auth/react.d.ts +140 -0
- package/dist/types/auth/react.d.ts.map +1 -0
- package/dist/types/auth/server.d.ts +124 -0
- package/dist/types/auth/server.d.ts.map +1 -0
- package/dist/types/client.d.ts +128 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/errors.d.ts +17 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/query-cache.d.ts +59 -0
- package/dist/types/query-cache.d.ts.map +1 -0
- package/dist/types/resources/auth.d.ts +188 -0
- package/dist/types/resources/auth.d.ts.map +1 -0
- package/dist/types/resources/calls.d.ts +27 -0
- package/dist/types/resources/calls.d.ts.map +1 -0
- package/dist/types/resources/chat.d.ts +24 -0
- package/dist/types/resources/chat.d.ts.map +1 -0
- package/dist/types/resources/db.d.ts +81 -0
- package/dist/types/resources/db.d.ts.map +1 -0
- package/dist/types/resources/emails.d.ts +20 -0
- package/dist/types/resources/emails.d.ts.map +1 -0
- package/dist/types/resources/fs.d.ts +68 -0
- package/dist/types/resources/fs.d.ts.map +1 -0
- package/dist/types/resources/functions.d.ts +30 -0
- package/dist/types/resources/functions.d.ts.map +1 -0
- package/dist/types/resources/inbox.d.ts +52 -0
- package/dist/types/resources/inbox.d.ts.map +1 -0
- package/dist/types/resources/payments.d.ts +31 -0
- package/dist/types/resources/payments.d.ts.map +1 -0
- package/dist/types/resources/postgrest.d.ts +121 -0
- package/dist/types/resources/postgrest.d.ts.map +1 -0
- package/dist/types/resources/projects.d.ts +79 -0
- package/dist/types/resources/projects.d.ts.map +1 -0
- package/dist/types/resources/storage.d.ts +63 -0
- package/dist/types/resources/storage.d.ts.map +1 -0
- package/dist/types/resources/tasks.d.ts +87 -0
- package/dist/types/resources/tasks.d.ts.map +1 -0
- package/dist/types/resources/video.d.ts +30 -0
- package/dist/types/resources/video.d.ts.map +1 -0
- package/dist/types/types.d.ts +379 -0
- package/dist/types/types.d.ts.map +1 -0
- 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
|