@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.2
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 +225 -19
- package/dist/core/billing/operationState.js +93 -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 +63 -9
- package/dist/core/billing/subscriptionCommands.js +46 -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/requestTimeout.d.ts +7 -0
- package/dist/core/requestTimeout.js +13 -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 +35 -0
- package/dist/core/sessionTokenMint.js +222 -0
- package/dist/core/webSession.d.ts +27 -0
- package/dist/core/webSession.js +144 -0
- package/dist/core/webSessionFlag.d.ts +19 -0
- package/dist/core/webSessionFlag.js +61 -0
- package/dist/core/webSessionIdentity.d.ts +297 -0
- package/dist/core/webSessionIdentity.js +509 -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
package/dist/firebase/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
23
|
-
readonly 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`
|
|
29
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
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>;
|
package/dist/firebase/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|