@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.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.
- package/dist/core/billing/billingContracts.d.ts +29 -14
- package/dist/core/billing/billingErrorBody.d.ts +8 -4
- package/dist/core/billing/billingErrorBody.js +14 -4
- package/dist/core/billing/credentialedTransport.d.ts +23 -7
- package/dist/core/billing/credentialedTransport.js +72 -8
- package/dist/core/billing/events.d.ts +47 -0
- package/dist/core/billing/events.js +43 -0
- package/dist/core/billing/index.d.ts +16 -11
- package/dist/core/billing/index.js +8 -6
- package/dist/core/billing/operationLifecycle.d.ts +34 -1
- package/dist/core/billing/operationLifecycle.js +146 -67
- package/dist/core/billing/operationPointer.d.ts +27 -2
- package/dist/core/billing/operationPointer.js +36 -25
- package/dist/core/billing/operationPolicy.d.ts +44 -7
- package/dist/core/billing/operationPolicy.js +64 -3
- package/dist/core/billing/operationState.d.ts +102 -18
- package/dist/core/billing/operationState.js +78 -8
- package/dist/core/billing/paymentCopy.d.ts +6 -1
- package/dist/core/billing/paymentCopy.js +34 -1
- package/dist/core/billing/paymentProjection.d.ts +14 -2
- package/dist/core/billing/paymentProjection.js +37 -9
- package/dist/core/billing/scopedReader.d.ts +11 -5
- package/dist/core/billing/scopedReader.js +20 -9
- package/dist/core/billing/sharedRead.js +4 -2
- package/dist/core/billing/subscriptionCommands.d.ts +37 -9
- package/dist/core/billing/subscriptionCommands.js +42 -30
- package/dist/core/billing/topup.d.ts +26 -0
- package/dist/core/billing/topup.js +36 -2
- package/dist/core/billing/wireCents.d.ts +8 -0
- package/dist/core/billing/wireCents.js +8 -0
- package/dist/core/billing/workspaceInvites.d.ts +12 -0
- package/dist/core/billing/workspaceInvites.js +26 -0
- package/dist/core/identity.d.ts +6 -6
- package/dist/core/identity.js +4 -10
- package/dist/core/lazyIdentity.d.ts +31 -0
- package/dist/core/lazyIdentity.js +96 -0
- package/dist/core/requestAuth.d.ts +30 -0
- package/dist/core/requestAuth.js +41 -0
- package/dist/core/session.d.ts +12 -18
- package/dist/core/session.js +16 -26
- package/dist/core/sessionContracts.d.ts +39 -2
- package/dist/core/sessionTokenMint.d.ts +33 -0
- package/dist/core/sessionTokenMint.js +205 -0
- package/dist/core/webSession.d.ts +25 -0
- package/dist/core/webSession.js +137 -0
- package/dist/core/webSessionFlag.d.ts +20 -0
- package/dist/core/webSessionFlag.js +62 -0
- package/dist/core/webSessionIdentity.d.ts +291 -0
- package/dist/core/webSessionIdentity.js +496 -0
- package/dist/firebase/configSource.d.ts +54 -0
- package/dist/firebase/configSource.js +111 -0
- package/dist/firebase/index.d.ts +71 -8
- package/dist/firebase/index.js +236 -23
- package/dist/firebase/popupWatch.d.ts +42 -0
- package/dist/firebase/popupWatch.js +140 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +116 -0
- package/dist/web/crossTabRefresh.d.ts +4 -2
- package/dist/web/crossTabRefresh.js +13 -0
- package/dist/workspaceLink.d.ts +45 -0
- package/dist/workspaceLink.js +78 -0
- package/package.json +30 -6
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-backed identity source: boots a site from `GET /auth/session`
|
|
3
|
+
* and follows the server's answer. It keeps no record of past sessions, and
|
|
4
|
+
* the remembered provider login is an opaque port, so no provider leaks in.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { createWebSession, deleteWebSession, readWebSession } from './webSession.js';
|
|
8
|
+
const read = { type: 'read' };
|
|
9
|
+
const signOutLocally = { type: 'sign_out_locally' };
|
|
10
|
+
function signOutDifferentLogin(session, rememberedUserId) {
|
|
11
|
+
return rememberedUserId !== null && rememberedUserId !== session.user.id
|
|
12
|
+
? [signOutLocally]
|
|
13
|
+
: [];
|
|
14
|
+
}
|
|
15
|
+
function settleSignedIn(session, rememberedUserId, outcome) {
|
|
16
|
+
return {
|
|
17
|
+
state: { phase: 'signed_in', session },
|
|
18
|
+
effects: [
|
|
19
|
+
...signOutDifferentLogin(session, rememberedUserId),
|
|
20
|
+
{ type: 'report', outcome }
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function settleSignedOut(outcome) {
|
|
25
|
+
return {
|
|
26
|
+
state: { phase: 'signed_out', outcome },
|
|
27
|
+
effects: [
|
|
28
|
+
...(outcome === 'revoked' ? [signOutLocally] : []),
|
|
29
|
+
{ type: 'report', outcome }
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function waitToRetry(failures) {
|
|
34
|
+
return {
|
|
35
|
+
state: { phase: 'retry_wait', failures },
|
|
36
|
+
effects: [{ type: 'schedule_retry', failures }]
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function isNoLiveSession(failure) {
|
|
40
|
+
return failure.code === 'NO_SESSION' || failure.code === 'SESSION_EXPIRED';
|
|
41
|
+
}
|
|
42
|
+
function applyReadAnswered(failures, result, rememberedUserId) {
|
|
43
|
+
if (result.status === 'ok') {
|
|
44
|
+
return settleSignedIn(result.session, rememberedUserId, 'signed_in');
|
|
45
|
+
}
|
|
46
|
+
if (result.retryable)
|
|
47
|
+
return waitToRetry(failures + 1);
|
|
48
|
+
if (result.code === 'SESSION_REVOKED')
|
|
49
|
+
return settleSignedOut('revoked');
|
|
50
|
+
if (!isNoLiveSession(result))
|
|
51
|
+
return settleSignedOut('signed_out');
|
|
52
|
+
if (rememberedUserId === null)
|
|
53
|
+
return settleSignedOut('signed_out');
|
|
54
|
+
return {
|
|
55
|
+
state: { phase: 'restoring', failures },
|
|
56
|
+
effects: [{ type: 'restore', expectedUserId: rememberedUserId }]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function applyRestoreAnswered(failures, result, rememberedUserId) {
|
|
60
|
+
if (result.status === 'ok') {
|
|
61
|
+
return settleSignedIn(result.session, rememberedUserId, 'restored');
|
|
62
|
+
}
|
|
63
|
+
if (result.retryable)
|
|
64
|
+
return waitToRetry(failures + 1);
|
|
65
|
+
if (result.code === 'SESSION_REVOKED')
|
|
66
|
+
return settleSignedOut('revoked');
|
|
67
|
+
return settleSignedOut('restore_failed');
|
|
68
|
+
}
|
|
69
|
+
function leaveAccount(outcome, effects = []) {
|
|
70
|
+
return {
|
|
71
|
+
state: { phase: 'signed_out', outcome },
|
|
72
|
+
effects: [
|
|
73
|
+
...effects,
|
|
74
|
+
{ type: 'account_changed', change: { reason: 'signed_out', outcome } }
|
|
75
|
+
]
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function replaceSession(held, session, effects = []) {
|
|
79
|
+
const state = { phase: 'signed_in', session };
|
|
80
|
+
if (session.user.id === held.user.id)
|
|
81
|
+
return { state, effects: [] };
|
|
82
|
+
return {
|
|
83
|
+
state,
|
|
84
|
+
effects: [
|
|
85
|
+
...effects,
|
|
86
|
+
{ type: 'account_changed', change: { reason: 'user_changed', session } }
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** A read never signs out on doubt: only revoked or no live session does. */
|
|
91
|
+
function observeWhileSignedIn(state, result, rememberedUserId, restored, ownRead) {
|
|
92
|
+
if (result.status === 'ok') {
|
|
93
|
+
return replaceSession(state.session, result.session, signOutDifferentLogin(result.session, rememberedUserId));
|
|
94
|
+
}
|
|
95
|
+
if (result.retryable)
|
|
96
|
+
return { state, effects: [] };
|
|
97
|
+
if (result.code === 'SESSION_REVOKED') {
|
|
98
|
+
return leaveAccount('revoked', ownRead
|
|
99
|
+
? [signOutLocally, { type: 'report_signed_out_remotely' }]
|
|
100
|
+
: [signOutLocally]);
|
|
101
|
+
}
|
|
102
|
+
if (restored)
|
|
103
|
+
return leaveAccount('restore_failed');
|
|
104
|
+
if (!isNoLiveSession(result))
|
|
105
|
+
return { state, effects: [] };
|
|
106
|
+
if (rememberedUserId === null)
|
|
107
|
+
return leaveAccount('signed_out');
|
|
108
|
+
return {
|
|
109
|
+
state,
|
|
110
|
+
effects: [{ type: 'restore', expectedUserId: rememberedUserId }]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function transitionSignedIn(state, event) {
|
|
114
|
+
switch (event.type) {
|
|
115
|
+
case 'session_observed':
|
|
116
|
+
return observeWhileSignedIn(state, event.result, event.rememberedUserId, false, event.from === 'this_tab');
|
|
117
|
+
case 'restore_answered':
|
|
118
|
+
return observeWhileSignedIn(state, event.result, event.rememberedUserId, true, true);
|
|
119
|
+
case 'session_created':
|
|
120
|
+
return replaceSession(state.session, event.session);
|
|
121
|
+
case 'proof_missing':
|
|
122
|
+
return leaveAccount('restore_failed');
|
|
123
|
+
case 'sign_out_requested':
|
|
124
|
+
return leaveAccount('signed_out', [signOutLocally]);
|
|
125
|
+
default:
|
|
126
|
+
return { state, effects: [] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function transitionRestoring(state, event) {
|
|
130
|
+
switch (event.type) {
|
|
131
|
+
case 'restore_answered':
|
|
132
|
+
return applyRestoreAnswered(state.failures, event.result, event.rememberedUserId);
|
|
133
|
+
case 'proof_missing':
|
|
134
|
+
return settleSignedOut('restore_failed');
|
|
135
|
+
case 'proof_errored':
|
|
136
|
+
case 'login_errored':
|
|
137
|
+
return waitToRetry(state.failures + 1);
|
|
138
|
+
default:
|
|
139
|
+
return { state, effects: [] };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function transitionBoot(state, event) {
|
|
143
|
+
switch (state.phase) {
|
|
144
|
+
case 'reading':
|
|
145
|
+
if (event.type === 'login_errored')
|
|
146
|
+
return waitToRetry(state.failures + 1);
|
|
147
|
+
return event.type === 'read_answered'
|
|
148
|
+
? applyReadAnswered(state.failures, event.result, event.rememberedUserId)
|
|
149
|
+
: { state, effects: [] };
|
|
150
|
+
case 'retry_wait':
|
|
151
|
+
return event.type === 'retry_due'
|
|
152
|
+
? { state: { ...state, phase: 'reading' }, effects: [read] }
|
|
153
|
+
: { state, effects: [] };
|
|
154
|
+
case 'restoring':
|
|
155
|
+
return transitionRestoring(state, event);
|
|
156
|
+
default:
|
|
157
|
+
return { state, effects: [] };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function transitionSettling(state, event) {
|
|
161
|
+
switch (event.type) {
|
|
162
|
+
case 'session_observed': {
|
|
163
|
+
const { result } = event;
|
|
164
|
+
const adoptable = state.phase === 'retry_wait' ||
|
|
165
|
+
(state.phase === 'signed_out' && event.from === 'sibling_sign_in');
|
|
166
|
+
if (result.status !== 'ok' || !adoptable)
|
|
167
|
+
return { state, effects: [] };
|
|
168
|
+
return {
|
|
169
|
+
state: { phase: 'signed_in', session: result.session },
|
|
170
|
+
effects: signOutDifferentLogin(result.session, event.rememberedUserId)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
case 'session_created':
|
|
174
|
+
return {
|
|
175
|
+
state: { phase: 'signed_in', session: event.session },
|
|
176
|
+
effects: []
|
|
177
|
+
};
|
|
178
|
+
case 'sign_out_requested':
|
|
179
|
+
return state.phase === 'signed_out'
|
|
180
|
+
? { state, effects: [] }
|
|
181
|
+
: {
|
|
182
|
+
state: { phase: 'signed_out', outcome: 'signed_out' },
|
|
183
|
+
effects: [signOutLocally]
|
|
184
|
+
};
|
|
185
|
+
default:
|
|
186
|
+
return transitionBoot(state, event);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Pure: an event that means nothing in the current phase changes nothing. */
|
|
190
|
+
export function transitionWebSessionIdentity(state, event) {
|
|
191
|
+
switch (state.phase) {
|
|
192
|
+
case 'idle':
|
|
193
|
+
return event.type === 'boot'
|
|
194
|
+
? { state: { phase: 'reading', failures: 0 }, effects: [read] }
|
|
195
|
+
: { state, effects: [] };
|
|
196
|
+
case 'api_key':
|
|
197
|
+
return { state, effects: [] };
|
|
198
|
+
case 'signed_in':
|
|
199
|
+
return transitionSignedIn(state, event);
|
|
200
|
+
default:
|
|
201
|
+
return transitionSettling(state, event);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const HEARTBEAT_KEY = '@comfyorg/account-core web-session heartbeat';
|
|
205
|
+
const HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
|
|
206
|
+
const zSharedResult = z.discriminatedUnion('status', [
|
|
207
|
+
z.object({
|
|
208
|
+
status: z.literal('ok'),
|
|
209
|
+
session: z.object({
|
|
210
|
+
user: z.object({
|
|
211
|
+
id: z.string(),
|
|
212
|
+
email: z.string(),
|
|
213
|
+
name: z.string().optional(),
|
|
214
|
+
emailVerified: z.boolean(),
|
|
215
|
+
signInProvider: z.string().optional()
|
|
216
|
+
}),
|
|
217
|
+
csrfToken: z.string(),
|
|
218
|
+
expiresAt: z.number(),
|
|
219
|
+
absoluteExpiresAt: z.number()
|
|
220
|
+
})
|
|
221
|
+
}),
|
|
222
|
+
z.object({
|
|
223
|
+
status: z.literal('error'),
|
|
224
|
+
code: z.literal('SESSION_REVOKED'),
|
|
225
|
+
retryable: z.literal(false)
|
|
226
|
+
})
|
|
227
|
+
]);
|
|
228
|
+
/**
|
|
229
|
+
* Every tab of a site reads the same host-only cookie, so a sibling's answer
|
|
230
|
+
* is this tab's own session observed elsewhere; that is what makes adopting
|
|
231
|
+
* it safe. A per-tab session scope would break this assumption.
|
|
232
|
+
*/
|
|
233
|
+
const zSharedMessage = z.object({
|
|
234
|
+
from: z.enum(['heartbeat', 'sign_in', 'sign_out']),
|
|
235
|
+
result: zSharedResult
|
|
236
|
+
});
|
|
237
|
+
const revoked = {
|
|
238
|
+
status: 'error',
|
|
239
|
+
code: 'SESSION_REVOKED',
|
|
240
|
+
retryable: false
|
|
241
|
+
};
|
|
242
|
+
const scheduleWithTimer = (run, delayMs) => {
|
|
243
|
+
const timer = setTimeout(run, delayMs);
|
|
244
|
+
return () => clearTimeout(timer);
|
|
245
|
+
};
|
|
246
|
+
function reportLocalSignOutFailure(error) {
|
|
247
|
+
console.warn('webSessionIdentity: local sign-out failed', error);
|
|
248
|
+
}
|
|
249
|
+
function createApiKeyIdentity() {
|
|
250
|
+
const state = { phase: 'api_key' };
|
|
251
|
+
return {
|
|
252
|
+
getState: () => state,
|
|
253
|
+
subscribe: (listener) => {
|
|
254
|
+
listener(state);
|
|
255
|
+
return () => undefined;
|
|
256
|
+
},
|
|
257
|
+
boot: () => undefined,
|
|
258
|
+
signedIn: async () => ({
|
|
259
|
+
status: 'error',
|
|
260
|
+
code: 'SESSION_REQUEST_REFUSED',
|
|
261
|
+
retryable: false
|
|
262
|
+
}),
|
|
263
|
+
signOut: async () => ({ status: 'ok' }),
|
|
264
|
+
getEpoch: () => 0,
|
|
265
|
+
dispose: () => undefined
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function createAccountIdentity(options, login) {
|
|
269
|
+
const schedule = options.schedule ?? scheduleWithTimer;
|
|
270
|
+
const retryBaseMs = options.retryBaseMs ?? 1000;
|
|
271
|
+
const retryMaxMs = options.retryMaxMs ?? 60_000;
|
|
272
|
+
const heartbeat = options.heartbeat;
|
|
273
|
+
const listeners = new Set();
|
|
274
|
+
let state = { phase: 'idle' };
|
|
275
|
+
let epoch = 0;
|
|
276
|
+
let signOuts = 0;
|
|
277
|
+
let signIns = 0;
|
|
278
|
+
let liveSignIn = 0;
|
|
279
|
+
let cancelRetry;
|
|
280
|
+
let cancelBeat;
|
|
281
|
+
let releaseLeadership;
|
|
282
|
+
let stopWatching = [];
|
|
283
|
+
function dispatch(event) {
|
|
284
|
+
const next = transitionWebSessionIdentity(state, event);
|
|
285
|
+
const changed = next.state !== state;
|
|
286
|
+
state = next.state;
|
|
287
|
+
next.effects.forEach(run);
|
|
288
|
+
if (changed)
|
|
289
|
+
listeners.forEach((listener) => listener(state));
|
|
290
|
+
syncLeadership();
|
|
291
|
+
}
|
|
292
|
+
function publish(from, result) {
|
|
293
|
+
heartbeat?.crossTab?.publishCredential(HEARTBEAT_KEY, { from, result });
|
|
294
|
+
}
|
|
295
|
+
async function answer(started, answered, request) {
|
|
296
|
+
const result = await request();
|
|
297
|
+
const remembered = await login.currentUserId().then((value) => ({ value }), () => undefined);
|
|
298
|
+
if (started !== epoch)
|
|
299
|
+
return;
|
|
300
|
+
// A failed lookup matters only without a live session to adopt: with one,
|
|
301
|
+
// it can skip nothing but the different-user sign-out.
|
|
302
|
+
if (remembered === undefined && result.status !== 'ok') {
|
|
303
|
+
return dispatch({ type: 'login_errored' });
|
|
304
|
+
}
|
|
305
|
+
dispatch({
|
|
306
|
+
...answered,
|
|
307
|
+
result,
|
|
308
|
+
rememberedUserId: remembered?.value ?? null
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
function beat() {
|
|
312
|
+
const started = epoch;
|
|
313
|
+
const observed = { type: 'session_observed', from: 'this_tab' };
|
|
314
|
+
void answer(started, observed, async () => {
|
|
315
|
+
const result = await readWebSession(options.session);
|
|
316
|
+
const shared = result.status === 'ok' || result.code === 'SESSION_REVOKED';
|
|
317
|
+
if (shared && started === epoch)
|
|
318
|
+
publish('heartbeat', result);
|
|
319
|
+
return result;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function invalidatePendingSignIns() {
|
|
323
|
+
signOuts += 1;
|
|
324
|
+
liveSignIn = 0;
|
|
325
|
+
}
|
|
326
|
+
function adopt(message) {
|
|
327
|
+
const parsed = zSharedMessage.safeParse(message);
|
|
328
|
+
if (!parsed.success)
|
|
329
|
+
return;
|
|
330
|
+
const { from, result } = parsed.data;
|
|
331
|
+
if (from === 'sign_out')
|
|
332
|
+
invalidatePendingSignIns();
|
|
333
|
+
const observed = {
|
|
334
|
+
type: 'session_observed',
|
|
335
|
+
from: `sibling_${from}`
|
|
336
|
+
};
|
|
337
|
+
void answer(epoch, observed, async () => result);
|
|
338
|
+
}
|
|
339
|
+
function wantsBeat() {
|
|
340
|
+
return (stopWatching.length > 0 &&
|
|
341
|
+
state.phase === 'signed_in' &&
|
|
342
|
+
heartbeat?.visibility.isVisible() === true);
|
|
343
|
+
}
|
|
344
|
+
function armBeat() {
|
|
345
|
+
cancelBeat = schedule(() => {
|
|
346
|
+
beat();
|
|
347
|
+
armBeat();
|
|
348
|
+
}, heartbeat?.intervalMs ?? HEARTBEAT_INTERVAL_MS);
|
|
349
|
+
}
|
|
350
|
+
function lead() {
|
|
351
|
+
if (wantsBeat() && cancelBeat === undefined)
|
|
352
|
+
armBeat();
|
|
353
|
+
}
|
|
354
|
+
function syncLeadership() {
|
|
355
|
+
const wanted = wantsBeat();
|
|
356
|
+
if (wanted && releaseLeadership === undefined) {
|
|
357
|
+
const port = heartbeat?.crossTab;
|
|
358
|
+
if (port) {
|
|
359
|
+
releaseLeadership = port.requestLeadership(HEARTBEAT_KEY, lead);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
releaseLeadership = () => undefined;
|
|
363
|
+
lead();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else if (!wanted && releaseLeadership !== undefined) {
|
|
367
|
+
releaseLeadership();
|
|
368
|
+
releaseLeadership = undefined;
|
|
369
|
+
cancelBeat?.();
|
|
370
|
+
cancelBeat = undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function watch() {
|
|
374
|
+
if (heartbeat === undefined)
|
|
375
|
+
return;
|
|
376
|
+
const { visibility, crossTab } = heartbeat;
|
|
377
|
+
stopWatching = [
|
|
378
|
+
visibility.onChange((visible) => {
|
|
379
|
+
if (visible && state.phase === 'signed_in')
|
|
380
|
+
beat();
|
|
381
|
+
syncLeadership();
|
|
382
|
+
}),
|
|
383
|
+
...(crossTab ? [crossTab.onCredential(HEARTBEAT_KEY, adopt)] : [])
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
async function restore(started, expectedUserId) {
|
|
387
|
+
const proof = await login.getProof().then((value) => ({ value }), () => undefined);
|
|
388
|
+
if (started !== epoch)
|
|
389
|
+
return;
|
|
390
|
+
if (proof === undefined)
|
|
391
|
+
return dispatch({ type: 'proof_errored' });
|
|
392
|
+
const { value } = proof;
|
|
393
|
+
if (value === null)
|
|
394
|
+
return dispatch({ type: 'proof_missing' });
|
|
395
|
+
await answer(started, { type: 'restore_answered' }, () => createWebSession(options.session, async () => value, { expectedUserId }));
|
|
396
|
+
}
|
|
397
|
+
function run(effect) {
|
|
398
|
+
switch (effect.type) {
|
|
399
|
+
case 'read':
|
|
400
|
+
void answer(epoch, { type: 'read_answered' }, () => readWebSession(options.session));
|
|
401
|
+
return;
|
|
402
|
+
case 'restore':
|
|
403
|
+
void restore(epoch, effect.expectedUserId);
|
|
404
|
+
return;
|
|
405
|
+
case 'schedule_retry':
|
|
406
|
+
cancelRetry = schedule(() => {
|
|
407
|
+
cancelRetry = undefined;
|
|
408
|
+
dispatch({ type: 'retry_due' });
|
|
409
|
+
}, Math.min(retryBaseMs * 2 ** (effect.failures - 1), retryMaxMs));
|
|
410
|
+
return;
|
|
411
|
+
case 'sign_out_locally':
|
|
412
|
+
login.signOutLocally().catch(reportLocalSignOutFailure);
|
|
413
|
+
return;
|
|
414
|
+
default:
|
|
415
|
+
notify(effect);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function notify(effect) {
|
|
419
|
+
switch (effect.type) {
|
|
420
|
+
case 'report':
|
|
421
|
+
options.onBootstrap?.({
|
|
422
|
+
outcome: effect.outcome,
|
|
423
|
+
origin: options.origin
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
case 'account_changed':
|
|
427
|
+
epoch += 1;
|
|
428
|
+
options.onAccountChanged?.({ ...effect.change, epoch });
|
|
429
|
+
return;
|
|
430
|
+
case 'report_signed_out_remotely':
|
|
431
|
+
options.onSignedOutRemotely?.({ origin: options.origin });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function stopHeartbeat() {
|
|
435
|
+
stopWatching.forEach((stop) => stop());
|
|
436
|
+
stopWatching = [];
|
|
437
|
+
syncLeadership();
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
getState: () => state,
|
|
441
|
+
subscribe: (listener) => {
|
|
442
|
+
listeners.add(listener);
|
|
443
|
+
listener(state);
|
|
444
|
+
return () => {
|
|
445
|
+
listeners.delete(listener);
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
boot: () => {
|
|
449
|
+
if (state.phase !== 'idle')
|
|
450
|
+
return;
|
|
451
|
+
watch();
|
|
452
|
+
dispatch({ type: 'boot' });
|
|
453
|
+
},
|
|
454
|
+
signedIn: async (getProof) => {
|
|
455
|
+
const signOutsAtStart = signOuts;
|
|
456
|
+
const signIn = ++signIns;
|
|
457
|
+
const result = await createWebSession(options.session, getProof);
|
|
458
|
+
if (result.status !== 'ok')
|
|
459
|
+
return result;
|
|
460
|
+
if (signOuts !== signOutsAtStart) {
|
|
461
|
+
// A later sign-in owns the cookie now; deleting would end its session.
|
|
462
|
+
if (liveSignIn > signIn)
|
|
463
|
+
return revoked;
|
|
464
|
+
const cleanup = await deleteWebSession(options.session);
|
|
465
|
+
return cleanup.status === 'ok' ? revoked : cleanup;
|
|
466
|
+
}
|
|
467
|
+
liveSignIn = signIn;
|
|
468
|
+
dispatch({ type: 'session_created', session: result.session });
|
|
469
|
+
publish('sign_in', result);
|
|
470
|
+
return result;
|
|
471
|
+
},
|
|
472
|
+
signOut: async () => {
|
|
473
|
+
invalidatePendingSignIns();
|
|
474
|
+
dispatch({ type: 'sign_out_requested' });
|
|
475
|
+
const result = await deleteWebSession(options.session);
|
|
476
|
+
if (result.status === 'ok')
|
|
477
|
+
publish('sign_out', revoked);
|
|
478
|
+
return result;
|
|
479
|
+
},
|
|
480
|
+
getEpoch: () => epoch,
|
|
481
|
+
dispose: () => {
|
|
482
|
+
epoch += 1;
|
|
483
|
+
cancelRetry?.();
|
|
484
|
+
cancelRetry = undefined;
|
|
485
|
+
stopHeartbeat();
|
|
486
|
+
state = { phase: 'idle' };
|
|
487
|
+
listeners.forEach((listener) => listener(state));
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/** Inert until `boot()`: constructing it sends no request and arms no timer. */
|
|
492
|
+
export function createWebSessionIdentity(options) {
|
|
493
|
+
return options.principal.kind === 'api_key'
|
|
494
|
+
? createApiKeyIdentity()
|
|
495
|
+
: createAccountIdentity(options, options.principal.rememberedLogin);
|
|
496
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud configuration read at runtime from the Cloud app's own
|
|
3
|
+
* `/api/features`, instead of values every host used to bake in at build
|
|
4
|
+
* time: the Firebase options, the Stripe publishable key, and the PostHog
|
|
5
|
+
* project and disabled events. Framework-free and dependency-free — it
|
|
6
|
+
* returns plain data and never imports the Firebase, Stripe or PostHog SDKs,
|
|
7
|
+
* so a caller decides if and when to use any of them.
|
|
8
|
+
*
|
|
9
|
+
* `fetchCloudFeatures` fetches the document once and reads every field from
|
|
10
|
+
* it; `fetchFirebaseConfig` and `fetchStripePublishableKey` are single-field
|
|
11
|
+
* conveniences built on top of it for a caller that only wants one. A host
|
|
12
|
+
* that wants both still calls `fetchCloudFeatures` directly — calling both
|
|
13
|
+
* conveniences would cost a second round trip.
|
|
14
|
+
*/
|
|
15
|
+
/** The subset of Firebase's own `FirebaseOptions` shape this fetch produces. */
|
|
16
|
+
export interface RuntimeFirebaseOptions {
|
|
17
|
+
readonly apiKey: string;
|
|
18
|
+
readonly authDomain: string;
|
|
19
|
+
readonly projectId: string;
|
|
20
|
+
readonly appId: string;
|
|
21
|
+
readonly databaseURL?: string;
|
|
22
|
+
readonly storageBucket?: string;
|
|
23
|
+
readonly messagingSenderId?: string;
|
|
24
|
+
readonly measurementId?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface CloudFeatures {
|
|
27
|
+
readonly firebaseConfig?: RuntimeFirebaseOptions;
|
|
28
|
+
/** Absent when the backend has no key configured, not an empty string. */
|
|
29
|
+
readonly stripePublishableKey?: string;
|
|
30
|
+
/** Present only for a literal `true` `web_session_probe`; see `../core/webSessionFlag.ts`. */
|
|
31
|
+
readonly webSessionProbe?: true;
|
|
32
|
+
/** The Cloud app's own PostHog project, so every Comfy surface reports into one. */
|
|
33
|
+
readonly posthogProjectToken?: string;
|
|
34
|
+
readonly posthogApiHost?: string;
|
|
35
|
+
/** Event names the backend has switched off for product analytics. */
|
|
36
|
+
readonly telemetryDisabledEvents?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export interface FetchFirebaseConfigOptions {
|
|
39
|
+
readonly fetchImpl?: typeof fetch;
|
|
40
|
+
readonly timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fetches the Cloud app's `/api/features` once and reads the Firebase
|
|
44
|
+
* options (`firebase_config`), the Stripe publishable key
|
|
45
|
+
* (`stripe_publishable_key`) and the PostHog settings (`posthog_project_token`,
|
|
46
|
+
* `posthog_api_host`, `telemetry_disabled_events`) from it. A field is absent
|
|
47
|
+
* when the response omits it, is malformed, or the fetch itself fails — this
|
|
48
|
+
* never throws.
|
|
49
|
+
*/
|
|
50
|
+
export declare function fetchCloudFeatures(cloudBaseUrl: string, options?: FetchFirebaseConfigOptions): Promise<CloudFeatures>;
|
|
51
|
+
/** Single-field convenience over `fetchCloudFeatures`; see the module comment before pairing it with another single-field call. */
|
|
52
|
+
export declare function fetchFirebaseConfig(cloudBaseUrl: string, options?: FetchFirebaseConfigOptions): Promise<RuntimeFirebaseOptions | undefined>;
|
|
53
|
+
/** Single-field convenience over `fetchCloudFeatures`; see the module comment before pairing it with another single-field call. */
|
|
54
|
+
export declare function fetchStripePublishableKey(cloudBaseUrl: string, options?: FetchFirebaseConfigOptions): Promise<string | undefined>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud configuration read at runtime from the Cloud app's own
|
|
3
|
+
* `/api/features`, instead of values every host used to bake in at build
|
|
4
|
+
* time: the Firebase options, the Stripe publishable key, and the PostHog
|
|
5
|
+
* project and disabled events. Framework-free and dependency-free — it
|
|
6
|
+
* returns plain data and never imports the Firebase, Stripe or PostHog SDKs,
|
|
7
|
+
* so a caller decides if and when to use any of them.
|
|
8
|
+
*
|
|
9
|
+
* `fetchCloudFeatures` fetches the document once and reads every field from
|
|
10
|
+
* it; `fetchFirebaseConfig` and `fetchStripePublishableKey` are single-field
|
|
11
|
+
* conveniences built on top of it for a caller that only wants one. A host
|
|
12
|
+
* that wants both still calls `fetchCloudFeatures` directly — calling both
|
|
13
|
+
* conveniences would cost a second round trip.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
16
|
+
function readString(source, key) {
|
|
17
|
+
const value = source[key];
|
|
18
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Same required-field rule as the build-time reader: everything Firebase Auth needs is required, the rest carried through when present. */
|
|
21
|
+
function parseFirebaseConfig(value) {
|
|
22
|
+
if (typeof value !== 'object' || value === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
const source = value;
|
|
25
|
+
const apiKey = readString(source, 'apiKey');
|
|
26
|
+
const authDomain = readString(source, 'authDomain');
|
|
27
|
+
const projectId = readString(source, 'projectId');
|
|
28
|
+
const appId = readString(source, 'appId');
|
|
29
|
+
if (!apiKey || !authDomain || !projectId || !appId)
|
|
30
|
+
return undefined;
|
|
31
|
+
return {
|
|
32
|
+
apiKey,
|
|
33
|
+
authDomain,
|
|
34
|
+
projectId,
|
|
35
|
+
appId,
|
|
36
|
+
databaseURL: readString(source, 'databaseURL'),
|
|
37
|
+
storageBucket: readString(source, 'storageBucket'),
|
|
38
|
+
messagingSenderId: readString(source, 'messagingSenderId'),
|
|
39
|
+
measurementId: readString(source, 'measurementId')
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** A non-string or empty value is treated the same as an absent field. */
|
|
43
|
+
function parseStripePublishableKey(value) {
|
|
44
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
function parseStringList(value) {
|
|
47
|
+
return Array.isArray(value)
|
|
48
|
+
? value.filter((item) => typeof item === 'string')
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Fetches `/api/features` from the given Cloud origin, an unauthenticated,
|
|
53
|
+
* cross-origin-readable endpoint. Resolves `undefined` on any problem — a
|
|
54
|
+
* non-OK response, an unparseable or non-object body, a network failure, or
|
|
55
|
+
* a fetch that outruns `timeoutMs` — so a caller can always fall back
|
|
56
|
+
* without branching on why the fetch failed.
|
|
57
|
+
*/
|
|
58
|
+
async function fetchFeaturesDocument(cloudBaseUrl, options) {
|
|
59
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetchImpl(`${cloudBaseUrl}/api/features`, {
|
|
65
|
+
signal: controller.signal
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok)
|
|
68
|
+
return undefined;
|
|
69
|
+
const body = await response.json();
|
|
70
|
+
return typeof body === 'object' && body !== null
|
|
71
|
+
? body
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fetches the Cloud app's `/api/features` once and reads the Firebase
|
|
83
|
+
* options (`firebase_config`), the Stripe publishable key
|
|
84
|
+
* (`stripe_publishable_key`) and the PostHog settings (`posthog_project_token`,
|
|
85
|
+
* `posthog_api_host`, `telemetry_disabled_events`) from it. A field is absent
|
|
86
|
+
* when the response omits it, is malformed, or the fetch itself fails — this
|
|
87
|
+
* never throws.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchCloudFeatures(cloudBaseUrl, options = {}) {
|
|
90
|
+
const body = await fetchFeaturesDocument(cloudBaseUrl, options);
|
|
91
|
+
if (!body)
|
|
92
|
+
return {};
|
|
93
|
+
return {
|
|
94
|
+
firebaseConfig: parseFirebaseConfig(body.firebase_config),
|
|
95
|
+
stripePublishableKey: parseStripePublishableKey(body.stripe_publishable_key),
|
|
96
|
+
posthogProjectToken: readString(body, 'posthog_project_token'),
|
|
97
|
+
posthogApiHost: readString(body, 'posthog_api_host'),
|
|
98
|
+
telemetryDisabledEvents: parseStringList(body.telemetry_disabled_events),
|
|
99
|
+
...(body.web_session_probe === true ? { webSessionProbe: true } : {})
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Single-field convenience over `fetchCloudFeatures`; see the module comment before pairing it with another single-field call. */
|
|
103
|
+
export async function fetchFirebaseConfig(cloudBaseUrl, options = {}) {
|
|
104
|
+
const features = await fetchCloudFeatures(cloudBaseUrl, options);
|
|
105
|
+
return features.firebaseConfig;
|
|
106
|
+
}
|
|
107
|
+
/** Single-field convenience over `fetchCloudFeatures`; see the module comment before pairing it with another single-field call. */
|
|
108
|
+
export async function fetchStripePublishableKey(cloudBaseUrl, options = {}) {
|
|
109
|
+
const features = await fetchCloudFeatures(cloudBaseUrl, options);
|
|
110
|
+
return features.stripePublishableKey;
|
|
111
|
+
}
|