@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.
Files changed (62) hide show
  1. package/dist/core/billing/billingContracts.d.ts +29 -14
  2. package/dist/core/billing/billingErrorBody.d.ts +8 -4
  3. package/dist/core/billing/billingErrorBody.js +14 -4
  4. package/dist/core/billing/credentialedTransport.d.ts +23 -7
  5. package/dist/core/billing/credentialedTransport.js +72 -8
  6. package/dist/core/billing/events.d.ts +47 -0
  7. package/dist/core/billing/events.js +43 -0
  8. package/dist/core/billing/index.d.ts +16 -11
  9. package/dist/core/billing/index.js +8 -6
  10. package/dist/core/billing/operationLifecycle.d.ts +34 -1
  11. package/dist/core/billing/operationLifecycle.js +146 -67
  12. package/dist/core/billing/operationPointer.d.ts +27 -2
  13. package/dist/core/billing/operationPointer.js +36 -25
  14. package/dist/core/billing/operationPolicy.d.ts +44 -7
  15. package/dist/core/billing/operationPolicy.js +64 -3
  16. package/dist/core/billing/operationState.d.ts +102 -18
  17. package/dist/core/billing/operationState.js +78 -8
  18. package/dist/core/billing/paymentCopy.d.ts +6 -1
  19. package/dist/core/billing/paymentCopy.js +34 -1
  20. package/dist/core/billing/paymentProjection.d.ts +14 -2
  21. package/dist/core/billing/paymentProjection.js +37 -9
  22. package/dist/core/billing/scopedReader.d.ts +11 -5
  23. package/dist/core/billing/scopedReader.js +20 -9
  24. package/dist/core/billing/sharedRead.js +4 -2
  25. package/dist/core/billing/subscriptionCommands.d.ts +37 -9
  26. package/dist/core/billing/subscriptionCommands.js +42 -30
  27. package/dist/core/billing/topup.d.ts +26 -0
  28. package/dist/core/billing/topup.js +36 -2
  29. package/dist/core/billing/wireCents.d.ts +8 -0
  30. package/dist/core/billing/wireCents.js +8 -0
  31. package/dist/core/billing/workspaceInvites.d.ts +12 -0
  32. package/dist/core/billing/workspaceInvites.js +26 -0
  33. package/dist/core/identity.d.ts +6 -6
  34. package/dist/core/identity.js +4 -10
  35. package/dist/core/lazyIdentity.d.ts +31 -0
  36. package/dist/core/lazyIdentity.js +96 -0
  37. package/dist/core/requestAuth.d.ts +30 -0
  38. package/dist/core/requestAuth.js +41 -0
  39. package/dist/core/session.d.ts +12 -18
  40. package/dist/core/session.js +16 -26
  41. package/dist/core/sessionContracts.d.ts +39 -2
  42. package/dist/core/sessionTokenMint.d.ts +33 -0
  43. package/dist/core/sessionTokenMint.js +205 -0
  44. package/dist/core/webSession.d.ts +25 -0
  45. package/dist/core/webSession.js +137 -0
  46. package/dist/core/webSessionFlag.d.ts +20 -0
  47. package/dist/core/webSessionFlag.js +62 -0
  48. package/dist/core/webSessionIdentity.d.ts +291 -0
  49. package/dist/core/webSessionIdentity.js +496 -0
  50. package/dist/firebase/configSource.d.ts +54 -0
  51. package/dist/firebase/configSource.js +111 -0
  52. package/dist/firebase/index.d.ts +71 -8
  53. package/dist/firebase/index.js +236 -23
  54. package/dist/firebase/popupWatch.d.ts +42 -0
  55. package/dist/firebase/popupWatch.js +140 -0
  56. package/dist/testing.d.ts +38 -0
  57. package/dist/testing.js +116 -0
  58. package/dist/web/crossTabRefresh.d.ts +4 -2
  59. package/dist/web/crossTabRefresh.js +13 -0
  60. package/dist/workspaceLink.d.ts +45 -0
  61. package/dist/workspaceLink.js +78 -0
  62. package/package.json +30 -6
@@ -13,36 +13,69 @@
13
13
  * importGuard.test.ts holds `./core` to that boundary.
14
14
  */
15
15
  import type { FirebaseOptions } from 'firebase/app';
16
- import type { Auth, Persistence, User, UserCredential } from 'firebase/auth';
16
+ import type { Auth, Dependencies, User, UserCredential } from 'firebase/auth';
17
17
  import type { AccountIdentity } from '../core/identity.js';
18
+ import type { CloudFeatures } from './configSource.js';
19
+ export { watchedPopupRedirectResolver } from './popupWatch.js';
18
20
  export interface FirebaseIdentityAppConfig {
19
- readonly options: FirebaseOptions;
21
+ readonly options: FirebaseOptions | (() => FirebaseOptions);
20
22
  /** Named app: never contend with a default app another script creates. */
21
23
  readonly appName?: string;
22
- /** Host-selected persistence; Firebase's default when omitted. */
23
- readonly persistence?: Persistence;
24
+ /** One persistence or an ordered hierarchy, handed to `initializeAuth` as is; Firebase's default when omitted. */
25
+ readonly persistence?: Dependencies['persistence'];
26
+ /**
27
+ * Report a closed popup at once instead of after Firebase's 8-10s wait (see
28
+ * `PopupSignInOptions`). Auth is then created with the dependencies `getAuth`
29
+ * would pick, plus `watchedPopupRedirectResolver`.
30
+ */
31
+ readonly watchPopupSignIn?: boolean;
24
32
  /** A host-owned `Auth` and package-owned app options are exclusive. */
25
33
  readonly auth?: never;
26
34
  }
27
35
  /**
28
- * A host that already holds an `Auth` (the cloud app's vuefire instance)
29
- * binds the entry to it: no second app, no second persistence store.
36
+ * A host that already holds an `Auth` binds the entry to it: no second app,
37
+ * no second persistence store.
30
38
  */
31
39
  export interface FirebaseIdentityAuthConfig {
32
40
  readonly auth: Auth;
41
+ /** Only when the host created `auth` with `watchedPopupRedirectResolver`. */
42
+ readonly watchPopupSignIn?: boolean;
33
43
  readonly options?: never;
34
44
  readonly appName?: never;
35
45
  readonly persistence?: never;
36
46
  }
37
47
  export type FirebaseIdentityConfig = FirebaseIdentityAppConfig | FirebaseIdentityAuthConfig;
48
+ /**
49
+ * On a watched identity a popup the visitor closes rejects at once with
50
+ * `auth/popup-closed-by-user`, the same error Firebase raises 8-10s later, so
51
+ * a host's usual handling applies. Rarely the sign-in result still arrives
52
+ * after that; these decide whether it is finished or discarded unused.
53
+ */
54
+ export interface PopupSignInOptions {
55
+ /**
56
+ * Finishes a result that arrived after the popup was reported closed, given
57
+ * the credential still being exchanged. Without it such a result is
58
+ * discarded. It is also discarded when another sign-in started since or
59
+ * someone else is now signed in.
60
+ */
61
+ readonly onResumed?: (credential: Promise<UserCredential>) => void;
62
+ /** Asked before a late result is kept; false discards it. */
63
+ readonly keepLateResult?: () => boolean;
64
+ }
38
65
  export interface FirebaseIdentity extends AccountIdentity<User> {
39
66
  /**
40
67
  * Fires with the restored user (or null) once Firebase settles, then on
41
68
  * every change. This is the identity the session core binds to.
42
69
  */
43
70
  onUserChanged: (callback: (user: User | null) => void) => () => void;
44
- signInWithGoogle: () => Promise<UserCredential>;
45
- signInWithGitHub: () => Promise<UserCredential>;
71
+ /** Fires on every ID token change, refreshes included. */
72
+ onTokenChanged: (callback: (user: User | null) => void) => () => void;
73
+ /** Resolves the app and `Auth` now; a no-op once resolved. */
74
+ initialize: () => void;
75
+ /** Null until `initialize()` or a subscribing/sign-in call has resolved `Auth`. */
76
+ currentUser: () => User | null;
77
+ signInWithGoogle: (options?: PopupSignInOptions) => Promise<UserCredential>;
78
+ signInWithGitHub: (options?: PopupSignInOptions) => Promise<UserCredential>;
46
79
  signInWithEmail: (email: string, password: string) => Promise<UserCredential>;
47
80
  createUserWithEmail: (email: string, password: string) => Promise<UserCredential>;
48
81
  sendPasswordReset: (email: string) => Promise<void>;
@@ -51,3 +84,33 @@ export interface FirebaseIdentity extends AccountIdentity<User> {
51
84
  signOut: () => Promise<void>;
52
85
  }
53
86
  export declare function createFirebaseIdentity(config: FirebaseIdentityConfig): FirebaseIdentity;
87
+ export interface ResolveFirebaseIdentityOptions {
88
+ /** The Cloud origin whose `/api/features` names this app's Firebase project. */
89
+ readonly cloudBaseUrl: string;
90
+ /** Named app: never contend with a default app another script creates. */
91
+ readonly appName: string;
92
+ readonly persistence?: Dependencies['persistence'];
93
+ readonly timeoutMs?: number;
94
+ }
95
+ export declare function resolveFirebaseIdentity(options: ResolveFirebaseIdentityOptions): Promise<FirebaseIdentity | undefined>;
96
+ export interface ResolveStripePublishableKeyOptions {
97
+ /** The Cloud origin whose `/api/features` names this app's Stripe key. */
98
+ readonly cloudBaseUrl: string;
99
+ readonly timeoutMs?: number;
100
+ }
101
+ /**
102
+ * The Stripe publishable key from the same `/api/features` document
103
+ * `resolveFirebaseIdentity` reads. A host that calls both with the same
104
+ * `cloudBaseUrl`/`timeoutMs` pays for one fetch, not two, `resolveCloudFeatures`
105
+ * dedupes by that pair regardless of which field a caller asked for first.
106
+ * Settles `undefined` on any failure and never rejects.
107
+ */
108
+ export declare function resolveStripePublishableKey(options: ResolveStripePublishableKeyOptions): Promise<string | undefined>;
109
+ export type CloudTelemetryConfig = Pick<CloudFeatures, 'posthogProjectToken' | 'posthogApiHost' | 'telemetryDisabledEvents'>;
110
+ /** The Cloud app's PostHog settings from the same shared `/api/features` document. */
111
+ export declare function resolveCloudTelemetryConfig(options: Pick<ResolveFirebaseIdentityOptions, 'cloudBaseUrl' | 'timeoutMs'>): Promise<CloudTelemetryConfig>;
112
+ /**
113
+ * `web_session_probe` from the same shared `/api/features` document, so a
114
+ * host that already reads it for Firebase or Stripe pays no extra request.
115
+ */
116
+ export declare function resolveWebSessionProbe(options: Pick<ResolveFirebaseIdentityOptions, 'cloudBaseUrl' | 'timeoutMs'>): Promise<boolean>;
@@ -1,7 +1,10 @@
1
- import { getApps, initializeApp } from 'firebase/app';
2
- import { GithubAuthProvider, GoogleAuthProvider, createUserWithEmailAndPassword, getAuth, initializeAuth, onAuthStateChanged, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup, signOut, updatePassword } from 'firebase/auth';
3
- import { identityBrand } from '../core/identity.js';
1
+ import { FirebaseError, getApps, initializeApp } from 'firebase/app';
2
+ import { GithubAuthProvider, GoogleAuthProvider, browserLocalPersistence, browserPopupRedirectResolver, browserSessionPersistence, createUserWithEmailAndPassword, getAuth, indexedDBLocalPersistence, initializeAuth, onAuthStateChanged, onIdTokenChanged, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup, signOut, updatePassword } from 'firebase/auth';
3
+ import { brandIdentity } from '../core/identity.js';
4
4
  import { isFirebaseAuthErrorLike } from '../firebaseAuthError.js';
5
+ import { fetchCloudFeatures } from './configSource.js';
6
+ import { runWatchedPopup, watchedPopupRedirectResolver } from './popupWatch.js';
7
+ export { watchedPopupRedirectResolver } from './popupWatch.js';
5
8
  function googleProvider() {
6
9
  const provider = new GoogleAuthProvider();
7
10
  provider.addScope('email');
@@ -25,43 +28,142 @@ function resolveUnknownEmailAsSent(error) {
25
28
  }
26
29
  throw error;
27
30
  }
31
+ /** What `getAuth` hands `initializeAuth` in a browser; read only when needed. */
32
+ function getAuthPersistence() {
33
+ return [
34
+ indexedDBLocalPersistence,
35
+ browserLocalPersistence,
36
+ browserSessionPersistence
37
+ ];
38
+ }
39
+ /**
40
+ * A pre-existing app under this name must be the same Firebase project, or
41
+ * `Auth` binds to another project's session. Deliberately a "same project"
42
+ * check on the fields that pick a session, not the SDK's byte-identical
43
+ * compare: `appId` is Installations/Analytics, so omitting it from a partial
44
+ * same-project config still binds rather than failing boot.
45
+ */
46
+ function assertSameProject(existing, requested, appName) {
47
+ const mismatch = ['projectId', 'apiKey', 'authDomain']
48
+ .filter((key) => existing[key] !== requested[key])
49
+ .join(', ');
50
+ if (mismatch) {
51
+ throw new Error(`Firebase app "${appName}" already exists for a different project (${mismatch})`);
52
+ }
53
+ }
54
+ /** What `initializeAuth` needs beyond `getAuth`'s defaults; none means `getAuth`. */
55
+ function authDependencies(persistence, watchPopupSignIn) {
56
+ if (!persistence && !watchPopupSignIn)
57
+ return undefined;
58
+ return {
59
+ persistence: persistence ?? getAuthPersistence(),
60
+ popupRedirectResolver: watchPopupSignIn
61
+ ? watchedPopupRedirectResolver
62
+ : browserPopupRedirectResolver
63
+ };
64
+ }
65
+ /**
66
+ * When only the popup watch asked for `initializeAuth`, an Auth that already
67
+ * exists for the app is reused as `getAuth` would, unwatched, rather than
68
+ * failing sign-in.
69
+ */
70
+ function initializeOrReuse(app, dependencies, mayReuse) {
71
+ try {
72
+ return initializeAuth(app, dependencies);
73
+ }
74
+ catch (error) {
75
+ if (mayReuse &&
76
+ isFirebaseAuthErrorLike(error) &&
77
+ error.code === 'auth/already-initialized') {
78
+ return getAuth(app);
79
+ }
80
+ throw error;
81
+ }
82
+ }
28
83
  /**
29
- * Resolved once per identity. A named app this entry creates gets the
30
- * host's persistence through `initializeAuth`; an app another entry already
31
- * created keeps the persistence its creator chose, since Firebase allows one
32
- * Auth per app.
84
+ * Host persistence goes through `initializeAuth`, whether this entry creates
85
+ * the named app or another script already did: Firebase allows one Auth per
86
+ * app, so an Auth another module initialized with different dependencies
87
+ * fails with `auth/already-initialized` instead of silently winning. Unlike
88
+ * `getAuth`, `initializeAuth` wires no popup resolver of its own, and popup
89
+ * sign-in throws `auth/argument-error` without one.
33
90
  */
34
91
  function authResolver(config) {
35
92
  if (config.auth) {
36
93
  const { auth } = config;
37
- return () => auth;
94
+ return { resolve: () => auth, peek: () => auth };
38
95
  }
39
96
  const appName = config.appName ?? 'comfy-account';
40
97
  let resolved;
41
- return () => {
98
+ const resolve = () => {
42
99
  if (resolved)
43
100
  return resolved;
101
+ // Resolve options before the lookup so the host's config thunk (its
102
+ // unloaded-remote-config guard) always runs, even when reusing an app.
103
+ const options = typeof config.options === 'function' ? config.options() : config.options;
44
104
  const existing = getApps().find((app) => app.name === appName);
45
- if (existing) {
46
- resolved = getAuth(existing);
47
- return resolved;
48
- }
49
- const app = initializeApp(config.options, appName);
50
- resolved = config.persistence
51
- ? initializeAuth(app, { persistence: config.persistence })
105
+ if (existing)
106
+ assertSameProject(existing.options, options, appName);
107
+ const app = existing ?? initializeApp(options, appName);
108
+ const dependencies = authDependencies(config.persistence, config.watchPopupSignIn);
109
+ resolved = dependencies
110
+ ? initializeOrReuse(app, dependencies, !config.persistence)
52
111
  : getAuth(app);
53
112
  return resolved;
54
113
  };
114
+ return { resolve, peek: () => resolved };
55
115
  }
56
116
  export function createFirebaseIdentity(config) {
57
- const auth = authResolver(config);
117
+ const { resolve: auth, peek } = authResolver(config);
118
+ let signInsStarted = 0;
119
+ function popupSignIn(createProvider, options) {
120
+ const started = ++signInsStarted;
121
+ const provider = createProvider();
122
+ const signIn = () => signInWithPopup(auth(), provider);
123
+ if (!config.watchPopupSignIn)
124
+ return signIn();
125
+ // Read once the session has restored, so a restore landing after the
126
+ // popup opened is not taken for someone else signing in.
127
+ let userAtStart = null;
128
+ // Never on the sign-in path: if the restore cannot be read, late results
129
+ // stay discarded.
130
+ void Promise.resolve()
131
+ .then(() => auth().authStateReady())
132
+ .then(() => {
133
+ userAtStart = auth().currentUser?.uid;
134
+ })
135
+ .catch(() => { });
136
+ return new Promise((resolve, reject) => {
137
+ const firebaseCall = runWatchedPopup(provider, signIn, {
138
+ onAbandoned: () => reject(new FirebaseError('auth/popup-closed-by-user', 'The popup has been closed by the user before finalizing the operation.')),
139
+ discardLateResult: () => !options?.onResumed ||
140
+ signInsStarted !== started ||
141
+ auth().currentUser?.uid !== userAtStart ||
142
+ options.keepLateResult?.() === false,
143
+ onResumed: () => options?.onResumed?.(firebaseCall)
144
+ });
145
+ firebaseCall.then(resolve, reject);
146
+ });
147
+ }
58
148
  return {
59
- [identityBrand]: true,
60
- onUserChanged: (callback) => onAuthStateChanged(auth(), callback),
61
- signInWithGoogle: () => signInWithPopup(auth(), googleProvider()),
62
- signInWithGitHub: () => signInWithPopup(auth(), githubProvider()),
63
- signInWithEmail: (email, password) => signInWithEmailAndPassword(auth(), email, password),
64
- createUserWithEmail: (email, password) => createUserWithEmailAndPassword(auth(), email, password),
149
+ ...brandIdentity({
150
+ onUserChanged: (callback) => onAuthStateChanged(auth(), callback)
151
+ }),
152
+ onTokenChanged: (callback) => onIdTokenChanged(auth(), callback),
153
+ initialize: () => {
154
+ auth();
155
+ },
156
+ currentUser: () => peek()?.currentUser ?? null,
157
+ signInWithGoogle: (options) => popupSignIn(googleProvider, options),
158
+ signInWithGitHub: (options) => popupSignIn(githubProvider, options),
159
+ signInWithEmail: (email, password) => {
160
+ signInsStarted += 1;
161
+ return signInWithEmailAndPassword(auth(), email, password);
162
+ },
163
+ createUserWithEmail: (email, password) => {
164
+ signInsStarted += 1;
165
+ return createUserWithEmailAndPassword(auth(), email, password);
166
+ },
65
167
  sendPasswordReset: (email) => sendPasswordResetEmail(auth(), email).catch(resolveUnknownEmailAsSent),
66
168
  updatePassword: (newPassword) => {
67
169
  const user = auth().currentUser;
@@ -72,3 +174,114 @@ export function createFirebaseIdentity(config) {
72
174
  signOut: () => signOut(auth())
73
175
  };
74
176
  }
177
+ /**
178
+ * One `/api/features` fetch per `cloudBaseUrl`/`timeoutMs` pair, shared by
179
+ * every reader of the document: `resolveFirebaseIdentity` and
180
+ * `resolveStripePublishableKey` on the same pair read the same fetch instead
181
+ * of each starting their own. Never rejects, a failed fetch settles `{}`,
182
+ * which each field reader treats as absent.
183
+ *
184
+ * Memoized while a fetch is in flight or has produced a document with either
185
+ * field present. A document with neither field is treated the same as a
186
+ * failed fetch and evicts, matching `identityResolutions` below: this cache
187
+ * cannot tell a transient failure (network error, timeout, malformed body)
188
+ * from a Cloud origin that genuinely has neither Firebase nor Stripe
189
+ * configured, and caching that ambiguity would silently defeat
190
+ * `identityResolutions`'s own eviction once a caller retries through it.
191
+ */
192
+ const featureResolutions = new Map();
193
+ function resolveCloudFeatures(cloudBaseUrl, timeoutMs) {
194
+ const key = `${cloudBaseUrl} ${timeoutMs ?? ''}`;
195
+ let resolution = featureResolutions.get(key);
196
+ if (!resolution) {
197
+ resolution = fetchCloudFeatures(cloudBaseUrl, { timeoutMs });
198
+ // Evict on an unsuccessful settle so a transient failure does not wedge
199
+ // every reader of this pair for the module's lifetime. Callers already
200
+ // hold this promise directly, not a map lookup, so deleting it here
201
+ // never orphans one.
202
+ void resolution.then((features) => {
203
+ if (!features.firebaseConfig &&
204
+ !features.stripePublishableKey &&
205
+ featureResolutions.get(key) === resolution) {
206
+ featureResolutions.delete(key);
207
+ }
208
+ });
209
+ featureResolutions.set(key, resolution);
210
+ }
211
+ return resolution;
212
+ }
213
+ /**
214
+ * The single entry a host needs to go from a Cloud origin to a ready
215
+ * identity: fetch `/api/features`, construct the app, and force `Auth` to
216
+ * resolve now rather than on whichever caller first touches the identity, so
217
+ * a config that fails the SDK's own checks (a bad key, a project mismatch on
218
+ * `appName`) surfaces here instead of downstream. Settles `undefined` on any
219
+ * failure along the way and never rejects, so a caller always gets either a
220
+ * ready identity or a definite absence.
221
+ *
222
+ * Memoized per `appName`/`cloudBaseUrl` pair while a fetch is in flight or
223
+ * has produced a ready identity, so every caller with the same pair shares
224
+ * one fetch and one identity, and two pairs (two hosts, or two Cloud origins
225
+ * in one process, as this package's own tests run) never share a result. An
226
+ * unsuccessful settle evicts its entry, so a later call re-fetches rather
227
+ * than replaying the same absence for the module's lifetime.
228
+ */
229
+ const identityResolutions = new Map();
230
+ export function resolveFirebaseIdentity(options) {
231
+ const { cloudBaseUrl, appName, persistence, timeoutMs } = options;
232
+ const key = `${appName} ${cloudBaseUrl}`;
233
+ let resolution = identityResolutions.get(key);
234
+ if (!resolution) {
235
+ resolution = resolveCloudFeatures(cloudBaseUrl, timeoutMs).then(({ firebaseConfig }) => {
236
+ if (!firebaseConfig)
237
+ return undefined;
238
+ try {
239
+ const identity = createFirebaseIdentity({
240
+ options: firebaseConfig,
241
+ appName,
242
+ persistence
243
+ });
244
+ identity.initialize();
245
+ return identity;
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ });
251
+ // Evict on an unsuccessful settle so a transient failure does not wedge
252
+ // sign-in for the module's lifetime. Callers already hold this promise
253
+ // directly, not a map lookup, so deleting it here never orphans one.
254
+ void resolution.then((identity) => {
255
+ if (!identity && identityResolutions.get(key) === resolution) {
256
+ identityResolutions.delete(key);
257
+ }
258
+ });
259
+ identityResolutions.set(key, resolution);
260
+ }
261
+ return resolution;
262
+ }
263
+ /**
264
+ * The Stripe publishable key from the same `/api/features` document
265
+ * `resolveFirebaseIdentity` reads. A host that calls both with the same
266
+ * `cloudBaseUrl`/`timeoutMs` pays for one fetch, not two, `resolveCloudFeatures`
267
+ * dedupes by that pair regardless of which field a caller asked for first.
268
+ * Settles `undefined` on any failure and never rejects.
269
+ */
270
+ export function resolveStripePublishableKey(options) {
271
+ return resolveCloudFeatures(options.cloudBaseUrl, options.timeoutMs).then(({ stripePublishableKey }) => stripePublishableKey);
272
+ }
273
+ /** The Cloud app's PostHog settings from the same shared `/api/features` document. */
274
+ export function resolveCloudTelemetryConfig(options) {
275
+ return resolveCloudFeatures(options.cloudBaseUrl, options.timeoutMs).then(({ posthogProjectToken, posthogApiHost, telemetryDisabledEvents }) => ({
276
+ posthogProjectToken,
277
+ posthogApiHost,
278
+ telemetryDisabledEvents
279
+ }));
280
+ }
281
+ /**
282
+ * `web_session_probe` from the same shared `/api/features` document, so a
283
+ * host that already reads it for Firebase or Stripe pays no extra request.
284
+ */
285
+ export function resolveWebSessionProbe(options) {
286
+ return resolveCloudFeatures(options.cloudBaseUrl, options.timeoutMs).then(({ webSessionProbe }) => webSessionProbe === true);
287
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tells, from facts rather than a timer, whether a popup sign-in was
3
+ * abandoned. Firebase itself cannot: after the window closes it waits a
4
+ * hardcoded 8s (on a 2s poll) before rejecting with `popup-closed-by-user`,
5
+ * because a closed window alone does not say whether the sign-in result had
6
+ * already been handed over.
7
+ *
8
+ * The result is handed over through the resolver's event manager, and
9
+ * Firebase's hosted handler page waits for the page to acknowledge it before
10
+ * closing the popup. So "window closed and no result for this operation" means
11
+ * the visitor gave up, and "result received" means the sign-in is completing,
12
+ * however long the token exchange then takes.
13
+ *
14
+ * This subclasses the SDK's public `browserPopupRedirectResolver` and reads two
15
+ * of its internals: the event manager `_initialize` resolves to, and the
16
+ * provider, event id and window `_openPopup` works with
17
+ * (popupWatchSdkContract.test.ts pins them). When they are not what this
18
+ * expects, nothing is watched and sign-in keeps Firebase's own timing.
19
+ */
20
+ import type { PopupRedirectResolver } from 'firebase/auth';
21
+ export interface PopupWatchCallbacks {
22
+ /** The popup closed before any sign-in result reached the page: the visitor gave up. */
23
+ readonly onAbandoned?: () => void;
24
+ /** A result arrived after `onAbandoned` and was kept: the sign-in completes after all. */
25
+ readonly onResumed?: () => void;
26
+ /**
27
+ * Asked when a result arrives after `onAbandoned`. True discards it before
28
+ * Firebase exchanges it, so no account is signed in or created.
29
+ */
30
+ readonly discardLateResult?: () => boolean;
31
+ }
32
+ /**
33
+ * The resolver a watched identity's Auth is created with. One for the whole
34
+ * page, so Auth created again for the same app is handed the same resolver.
35
+ * Firebase's own when its resolver cannot be extended.
36
+ */
37
+ export declare const watchedPopupRedirectResolver: PopupRedirectResolver;
38
+ /**
39
+ * Runs one popup sign-in for `provider`, the object passed to
40
+ * `signInWithPopup`, reporting through `callbacks` while it is pending.
41
+ */
42
+ export declare function runWatchedPopup<T>(provider: object, signIn: () => Promise<T>, callbacks: PopupWatchCallbacks): Promise<T>;
@@ -0,0 +1,140 @@
1
+ import { browserPopupRedirectResolver } from 'firebase/auth';
2
+ /** Frequent enough to feel immediate; one `closed` read per tick. */
3
+ const CLOSED_POLL_MS = 100;
4
+ // Keyed by the provider object each sign-in hands to `signInWithPopup`, which
5
+ // Firebase passes on to `_openPopup`, so a popup Firebase still opens for an
6
+ // operation it already cancelled is never taken for a newer one.
7
+ const attemptsByProvider = new WeakMap();
8
+ const attemptsByEventId = new Map();
9
+ const wrappedManagers = new WeakSet();
10
+ // Only an Auth whose event manager is wrapped has results this can see.
11
+ const observedAuths = new WeakSet();
12
+ function isObject(value) {
13
+ return typeof value === 'object' && value !== null;
14
+ }
15
+ function isResolverClass(value) {
16
+ if (typeof value !== 'function')
17
+ return false;
18
+ const prototype = value.prototype;
19
+ return (typeof prototype === 'object' &&
20
+ prototype !== null &&
21
+ '_initialize' in prototype &&
22
+ '_openPopup' in prototype);
23
+ }
24
+ function isEventManager(value) {
25
+ return (typeof value === 'object' &&
26
+ value !== null &&
27
+ 'onEvent' in value &&
28
+ typeof value.onEvent === 'function');
29
+ }
30
+ function popupWindowOf(popup) {
31
+ if (typeof popup !== 'object' || popup === null || !('window' in popup))
32
+ return undefined;
33
+ const { window } = popup;
34
+ return typeof window === 'object' && window !== null && 'closed' in window
35
+ ? window
36
+ : undefined;
37
+ }
38
+ function stopPolling(attempt) {
39
+ clearInterval(attempt.poll);
40
+ attempt.poll = undefined;
41
+ }
42
+ function deliver(original, event) {
43
+ const attempt = event.type === 'signInViaPopup' && typeof event.eventId === 'string'
44
+ ? attemptsByEventId.get(event.eventId)
45
+ : undefined;
46
+ if (!attempt)
47
+ return original(event);
48
+ stopPolling(attempt);
49
+ // A late failure is Firebase's to report; the host already heard the close.
50
+ if (!attempt.abandoned || event.error)
51
+ return original(event);
52
+ // Acknowledged so the handler page stops, never handed to Firebase.
53
+ if (shouldDiscard(attempt))
54
+ return true;
55
+ attempt.abandoned = false;
56
+ try {
57
+ attempt.callbacks.onResumed?.();
58
+ }
59
+ catch {
60
+ // The result is kept either way; a host bug must not stall Firebase.
61
+ }
62
+ return original(event);
63
+ }
64
+ function shouldDiscard(attempt) {
65
+ try {
66
+ return attempt.callbacks.discardLateResult?.() ?? false;
67
+ }
68
+ catch {
69
+ return true;
70
+ }
71
+ }
72
+ /** Ties the attempt to its latest popup, dropping any earlier one's watch. */
73
+ function watchWindow(attempt, eventId, popup) {
74
+ stopPolling(attempt);
75
+ if (attempt.eventId)
76
+ attemptsByEventId.delete(attempt.eventId);
77
+ attempt.eventId = eventId;
78
+ attemptsByEventId.set(eventId, attempt);
79
+ attempt.poll = setInterval(() => {
80
+ if (popup.closed !== true)
81
+ return;
82
+ stopPolling(attempt);
83
+ attempt.abandoned = true;
84
+ attempt.callbacks.onAbandoned?.();
85
+ }, CLOSED_POLL_MS);
86
+ }
87
+ function watchingResolver(Base) {
88
+ return class WatchingPopupRedirectResolver extends Base {
89
+ async _initialize(auth) {
90
+ const manager = await super._initialize(auth);
91
+ if (!isEventManager(manager))
92
+ return manager;
93
+ if (!wrappedManagers.has(manager)) {
94
+ const original = manager.onEvent.bind(manager);
95
+ manager.onEvent = (event) => deliver(original, event);
96
+ wrappedManagers.add(manager);
97
+ }
98
+ if (isObject(auth))
99
+ observedAuths.add(auth);
100
+ return manager;
101
+ }
102
+ async _openPopup(auth, provider, authType, eventId) {
103
+ const popup = await super._openPopup(auth, provider, authType, eventId);
104
+ const attempt = isObject(provider)
105
+ ? attemptsByProvider.get(provider)
106
+ : undefined;
107
+ const popupWindow = popupWindowOf(popup);
108
+ // Without results to compare against, a closed window proves nothing.
109
+ const observed = isObject(auth) && observedAuths.has(auth);
110
+ if (attempt && observed && eventId && popupWindow)
111
+ watchWindow(attempt, eventId, popupWindow);
112
+ return popup;
113
+ }
114
+ };
115
+ }
116
+ /**
117
+ * The resolver a watched identity's Auth is created with. One for the whole
118
+ * page, so Auth created again for the same app is handed the same resolver.
119
+ * Firebase's own when its resolver cannot be extended.
120
+ */
121
+ export const watchedPopupRedirectResolver = isResolverClass(browserPopupRedirectResolver)
122
+ ? watchingResolver(browserPopupRedirectResolver)
123
+ : browserPopupRedirectResolver;
124
+ /**
125
+ * Runs one popup sign-in for `provider`, the object passed to
126
+ * `signInWithPopup`, reporting through `callbacks` while it is pending.
127
+ */
128
+ export async function runWatchedPopup(provider, signIn, callbacks) {
129
+ const attempt = { callbacks, abandoned: false };
130
+ attemptsByProvider.set(provider, attempt);
131
+ try {
132
+ return await signIn();
133
+ }
134
+ finally {
135
+ stopPolling(attempt);
136
+ attemptsByProvider.delete(provider);
137
+ if (attempt.eventId)
138
+ attemptsByEventId.delete(attempt.eventId);
139
+ }
140
+ }
package/dist/testing.d.ts CHANGED
@@ -2,6 +2,44 @@
2
2
  * The test seam for fakes: a hand-written port becomes an identity the
3
3
  * session client accepts. Production hosts use `@comfyorg/account-core/firebase`.
4
4
  */
5
+ import type { WebSessionUser } from './core/sessionContracts.js';
5
6
  export type { IdentityPort } from './core/identity.js';
6
7
  export { brandIdentity as createTestIdentity } from './core/identity.js';
7
8
  export declare const ENUMERATION_ORACLE: RegExp;
9
+ export type { WebSessionUser } from './core/sessionContracts.js';
10
+ export declare function fakeWebSessionUser(overrides?: Partial<WebSessionUser>): WebSessionUser;
11
+ /** The five boot cases, plus any other transient status. */
12
+ export type FakeWebSessionState = {
13
+ readonly kind: 'live';
14
+ readonly user: WebSessionUser;
15
+ } | {
16
+ readonly kind: 'dead';
17
+ readonly code: 'no_session' | 'session_expired' | 'session_revoked';
18
+ } | {
19
+ readonly kind: 'network_error';
20
+ } | {
21
+ readonly kind: 'unavailable';
22
+ readonly status: number;
23
+ };
24
+ export interface FakeWebSessionRequest {
25
+ readonly method: string;
26
+ readonly path: string;
27
+ readonly credentials: RequestCredentials | undefined;
28
+ readonly cache: RequestCache | undefined;
29
+ readonly headers: Readonly<Record<string, string>>;
30
+ }
31
+ export interface FakeWebSessionEndpoint {
32
+ readonly fetch: typeof fetch;
33
+ readonly requests: readonly FakeWebSessionRequest[];
34
+ state: FakeWebSessionState;
35
+ }
36
+ /**
37
+ * A `fetch` serving ingest's session routes under `/api/auth`. POST with a
38
+ * bearer proof signs `signInUser` in; DELETE and revoke-all leave the cookie
39
+ * revoked, as the real endpoint does.
40
+ */
41
+ export declare function createFakeWebSessionEndpoint({ state, signInUser, now }: {
42
+ state: FakeWebSessionState;
43
+ signInUser?: WebSessionUser;
44
+ now?: () => number;
45
+ }): FakeWebSessionEndpoint;