@comfyorg/account-core 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/core/billing/balanceWatch.d.ts +35 -0
- package/dist/core/billing/balanceWatch.js +83 -0
- package/dist/core/billing/billingContracts.d.ts +123 -0
- package/dist/core/billing/billingContracts.js +11 -0
- package/dist/core/billing/billingErrorBody.d.ts +8 -0
- package/dist/core/billing/billingErrorBody.js +11 -0
- package/dist/core/billing/billingScope.d.ts +39 -0
- package/dist/core/billing/billingScope.js +45 -0
- package/dist/core/billing/capabilities.d.ts +70 -0
- package/dist/core/billing/capabilities.js +137 -0
- package/dist/core/billing/capabilityDenials.d.ts +26 -0
- package/dist/core/billing/capabilityDenials.js +43 -0
- package/dist/core/billing/challengeDriver.d.ts +22 -0
- package/dist/core/billing/challengeDriver.js +38 -0
- package/dist/core/billing/credentialedTransport.d.ts +35 -0
- package/dist/core/billing/credentialedTransport.js +53 -0
- package/dist/core/billing/credits.d.ts +43 -0
- package/dist/core/billing/credits.js +29 -0
- package/dist/core/billing/httpStatus.d.ts +7 -0
- package/dist/core/billing/httpStatus.js +25 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.js +23 -0
- package/dist/core/billing/operationLifecycle.d.ts +77 -0
- package/dist/core/billing/operationLifecycle.js +547 -0
- package/dist/core/billing/operationPointer.d.ts +48 -0
- package/dist/core/billing/operationPointer.js +89 -0
- package/dist/core/billing/operationPolicy.d.ts +26 -0
- package/dist/core/billing/operationPolicy.js +51 -0
- package/dist/core/billing/operationState.d.ts +128 -0
- package/dist/core/billing/operationState.js +187 -0
- package/dist/core/billing/paymentCopy.d.ts +18 -0
- package/dist/core/billing/paymentCopy.js +47 -0
- package/dist/core/billing/paymentMethods.d.ts +40 -0
- package/dist/core/billing/paymentMethods.js +37 -0
- package/dist/core/billing/paymentProjection.d.ts +34 -0
- package/dist/core/billing/paymentProjection.js +56 -0
- package/dist/core/billing/plans.d.ts +44 -0
- package/dist/core/billing/plans.js +29 -0
- package/dist/core/billing/presentation.d.ts +20 -0
- package/dist/core/billing/presentation.js +10 -0
- package/dist/core/billing/scopedReader.d.ts +61 -0
- package/dist/core/billing/scopedReader.js +108 -0
- package/dist/core/billing/sharedRead.d.ts +33 -0
- package/dist/core/billing/sharedRead.js +59 -0
- package/dist/core/billing/status.d.ts +27 -0
- package/dist/core/billing/status.js +21 -0
- package/dist/core/billing/subscriptionCommands.d.ts +367 -0
- package/dist/core/billing/subscriptionCommands.js +263 -0
- package/dist/core/billing/topup.d.ts +113 -0
- package/dist/core/billing/topup.js +209 -0
- package/dist/core/billing/transport.d.ts +14 -0
- package/dist/core/billing/transport.js +106 -0
- package/dist/core/billing/transportExchange.d.ts +24 -0
- package/dist/core/billing/transportExchange.js +79 -0
- package/dist/core/boundedOperation.d.ts +16 -0
- package/dist/core/boundedOperation.js +12 -0
- package/dist/core/credentialCache.d.ts +41 -0
- package/dist/core/credentialCache.js +97 -0
- package/dist/core/customerRecovery.d.ts +39 -0
- package/dist/core/customerRecovery.js +79 -0
- package/dist/core/exchange.d.ts +50 -0
- package/dist/core/exchange.js +137 -0
- package/dist/core/identity.d.ts +21 -0
- package/dist/core/identity.js +16 -0
- package/dist/core/mintCoordinator.d.ts +18 -0
- package/dist/core/mintCoordinator.js +31 -0
- package/dist/core/refreshScheduler.d.ts +46 -0
- package/dist/core/refreshScheduler.js +262 -0
- package/dist/core/session.d.ts +146 -0
- package/dist/core/session.js +312 -0
- package/dist/core/sessionContracts.d.ts +135 -0
- package/dist/core/sessionContracts.js +9 -0
- package/dist/core/sessionState.d.ts +118 -0
- package/dist/core/sessionState.js +235 -0
- package/dist/firebase/index.d.ts +53 -0
- package/dist/firebase/index.js +74 -0
- package/dist/firebaseAuthError.d.ts +52 -0
- package/dist/firebaseAuthError.js +88 -0
- package/dist/loadExternalScript.d.ts +7 -0
- package/dist/loadExternalScript.js +88 -0
- package/dist/provisioning.d.ts +53 -0
- package/dist/provisioning.js +76 -0
- package/dist/redirect.d.ts +13 -0
- package/dist/redirect.js +38 -0
- package/dist/signInSchemas.d.ts +88 -0
- package/dist/signInSchemas.js +71 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +26 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -0
- package/dist/turnstile.d.ts +14 -0
- package/dist/turnstile.js +17 -0
- package/dist/turnstileScript.d.ts +18 -0
- package/dist/turnstileScript.js +6 -0
- package/dist/web/crossTabRefresh.d.ts +18 -0
- package/dist/web/crossTabRefresh.js +121 -0
- package/dist/webviewDetection.d.ts +5 -0
- package/dist/webviewDetection.js +62 -0
- package/package.json +114 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { isPermanentSessionError } from './sessionContracts.js';
|
|
2
|
+
export function initialSessionState() {
|
|
3
|
+
return {
|
|
4
|
+
user: null,
|
|
5
|
+
identitySettled: false,
|
|
6
|
+
credential: undefined,
|
|
7
|
+
credentialTarget: undefined,
|
|
8
|
+
failure: undefined,
|
|
9
|
+
committedMint: undefined,
|
|
10
|
+
identityEpoch: 0,
|
|
11
|
+
invalidationEpoch: 0,
|
|
12
|
+
mintSequence: 0
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const clearStorage = { type: 'clearStorage' };
|
|
16
|
+
const stopScheduler = { type: 'stopScheduler' };
|
|
17
|
+
const abandonInFlight = { type: 'abandonInFlight' };
|
|
18
|
+
const publish = { type: 'publish' };
|
|
19
|
+
function dropSession(state) {
|
|
20
|
+
return {
|
|
21
|
+
...state,
|
|
22
|
+
credential: undefined,
|
|
23
|
+
credentialTarget: undefined,
|
|
24
|
+
failure: undefined
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function applyIdentityChanged(state, event) {
|
|
28
|
+
return {
|
|
29
|
+
state: {
|
|
30
|
+
...dropSession(state),
|
|
31
|
+
user: event.user,
|
|
32
|
+
identitySettled: true,
|
|
33
|
+
identityEpoch: state.identityEpoch + 1
|
|
34
|
+
},
|
|
35
|
+
// A sign-out or a different user makes the running mint unjoinable: it
|
|
36
|
+
// was started with the previous identity's token, and a caller arriving
|
|
37
|
+
// after the event must mint for itself. A same-uid re-auth must not
|
|
38
|
+
// adopt it either.
|
|
39
|
+
effects: event.user === null
|
|
40
|
+
? [stopScheduler, abandonInFlight, clearStorage, publish]
|
|
41
|
+
: [stopScheduler, abandonInFlight, publish]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function applyIdentityDetached(state) {
|
|
45
|
+
return {
|
|
46
|
+
state: {
|
|
47
|
+
...dropSession(state),
|
|
48
|
+
user: null,
|
|
49
|
+
identitySettled: false,
|
|
50
|
+
identityEpoch: state.identityEpoch + 1
|
|
51
|
+
},
|
|
52
|
+
effects: [stopScheduler, publish]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function applyInvalidated(state) {
|
|
56
|
+
// A mint still running belongs to the scope being discarded; a caller
|
|
57
|
+
// arriving after this must start its own rather than join it.
|
|
58
|
+
return {
|
|
59
|
+
state: {
|
|
60
|
+
...dropSession(state),
|
|
61
|
+
invalidationEpoch: state.invalidationEpoch + 1
|
|
62
|
+
},
|
|
63
|
+
effects: [stopScheduler, abandonInFlight, clearStorage, publish]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function applyMintStarted(state) {
|
|
67
|
+
return {
|
|
68
|
+
state: { ...state, mintSequence: state.mintSequence + 1 },
|
|
69
|
+
effects: []
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function applyMintCommitted(state, event) {
|
|
73
|
+
const target = event.origin === 'caller' ? event.target : state.credentialTarget;
|
|
74
|
+
const persist = {
|
|
75
|
+
type: 'persist',
|
|
76
|
+
session: event.session,
|
|
77
|
+
target
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
state: {
|
|
81
|
+
...state,
|
|
82
|
+
credential: event.session,
|
|
83
|
+
credentialTarget: target,
|
|
84
|
+
committedMint: { mintId: event.mintId, session: event.session },
|
|
85
|
+
failure: undefined
|
|
86
|
+
},
|
|
87
|
+
effects: event.origin === 'caller'
|
|
88
|
+
? [persist, { type: 'armScheduler', session: event.session }, publish]
|
|
89
|
+
: [persist, publish]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function applySchedulerRejection(state, failure) {
|
|
93
|
+
return {
|
|
94
|
+
state: { ...dropSession(state), failure },
|
|
95
|
+
effects: [clearStorage, publish]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function applyCallerRejection(state, event) {
|
|
99
|
+
const permanent = isPermanentSessionError(event.failure.code);
|
|
100
|
+
if (!permanent &&
|
|
101
|
+
event.preserveCredentialOnTransientFailure &&
|
|
102
|
+
state.credential !== undefined) {
|
|
103
|
+
return { state, effects: [] };
|
|
104
|
+
}
|
|
105
|
+
if (!permanent) {
|
|
106
|
+
return {
|
|
107
|
+
state: { ...state, credential: undefined, failure: event.failure },
|
|
108
|
+
effects: [publish]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// A caller-initiated permanent failure must retire the armed scheduler
|
|
112
|
+
// and target too, or its old timer could resurrect the dead session.
|
|
113
|
+
return {
|
|
114
|
+
state: { ...dropSession(state), failure: event.failure },
|
|
115
|
+
effects: [stopScheduler, clearStorage, publish]
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function applyMintRejected(state, event) {
|
|
119
|
+
return event.origin === 'scheduler'
|
|
120
|
+
? applySchedulerRejection(state, event.failure)
|
|
121
|
+
: applyCallerRejection(state, event);
|
|
122
|
+
}
|
|
123
|
+
function applyCredentialAdopted(state, event) {
|
|
124
|
+
// Adoption is a commit: it supersedes any in-flight mint of this
|
|
125
|
+
// tab's own, exactly like a newer mint would.
|
|
126
|
+
return {
|
|
127
|
+
state: {
|
|
128
|
+
...state,
|
|
129
|
+
mintSequence: state.mintSequence + 1,
|
|
130
|
+
credential: event.session,
|
|
131
|
+
failure: undefined
|
|
132
|
+
},
|
|
133
|
+
effects: [
|
|
134
|
+
{
|
|
135
|
+
type: 'persist',
|
|
136
|
+
session: event.session,
|
|
137
|
+
target: state.credentialTarget
|
|
138
|
+
},
|
|
139
|
+
publish
|
|
140
|
+
]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function applyCredentialExpired(state, event) {
|
|
144
|
+
if (state.credential !== event.expiring)
|
|
145
|
+
return { state, effects: [] };
|
|
146
|
+
return {
|
|
147
|
+
state: {
|
|
148
|
+
...dropSession(state),
|
|
149
|
+
failure: { status: 'error', code: 'TOKEN_EXCHANGE_FAILED' }
|
|
150
|
+
},
|
|
151
|
+
effects: [clearStorage, publish]
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export function transition(state, event) {
|
|
155
|
+
switch (event.type) {
|
|
156
|
+
case 'identity-changed':
|
|
157
|
+
return applyIdentityChanged(state, event);
|
|
158
|
+
case 'identity-detached':
|
|
159
|
+
return applyIdentityDetached(state);
|
|
160
|
+
case 'invalidated':
|
|
161
|
+
return applyInvalidated(state);
|
|
162
|
+
case 'mint-started':
|
|
163
|
+
return applyMintStarted(state);
|
|
164
|
+
case 'mint-committed':
|
|
165
|
+
return applyMintCommitted(state, event);
|
|
166
|
+
case 'mint-rejected':
|
|
167
|
+
return applyMintRejected(state, event);
|
|
168
|
+
case 'credential-adopted':
|
|
169
|
+
return applyCredentialAdopted(state, event);
|
|
170
|
+
case 'credential-expired':
|
|
171
|
+
return applyCredentialExpired(state, event);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const SUPERSEDED = { verdict: 'superseded' };
|
|
175
|
+
function supersededByInvalidation(state, attempt) {
|
|
176
|
+
return state.invalidationEpoch !== attempt.startInvalidation;
|
|
177
|
+
}
|
|
178
|
+
// The newest mint won, but when it committed a credential for this
|
|
179
|
+
// caller's exact target, that credential answers the request — a lost
|
|
180
|
+
// race is not a failure. A different target (or none) stays undefined.
|
|
181
|
+
function lostRace(state, attempt) {
|
|
182
|
+
if (attempt.mintId === state.mintSequence)
|
|
183
|
+
return undefined;
|
|
184
|
+
const answered = state.credential !== undefined &&
|
|
185
|
+
state.credential.uid === attempt.userUid &&
|
|
186
|
+
state.user?.uid === attempt.userUid &&
|
|
187
|
+
attempt.requestedTarget === state.credentialTarget;
|
|
188
|
+
return answered ? { verdict: 'reuse', session: state.credential } : SUPERSEDED;
|
|
189
|
+
}
|
|
190
|
+
function crossedIdentityEvent(state, attempt) {
|
|
191
|
+
return state.identityEpoch !== attempt.startEpoch;
|
|
192
|
+
}
|
|
193
|
+
// The one mint allowed to cross an identity event: an explicit-user
|
|
194
|
+
// mint started while signed out, for the user the port then
|
|
195
|
+
// delivered (the popup path) as the single event since. Everything else
|
|
196
|
+
// was minted for an identity that is gone, even when the uid matches again.
|
|
197
|
+
function popupSettled(state, attempt) {
|
|
198
|
+
return (attempt.explicitUser &&
|
|
199
|
+
attempt.startedSignedOut &&
|
|
200
|
+
state.identityEpoch === attempt.startEpoch + 1 &&
|
|
201
|
+
state.user?.uid === attempt.userUid);
|
|
202
|
+
}
|
|
203
|
+
// The explicit-user bypass is the popup path, valid only before the
|
|
204
|
+
// identity port has ever fired; a settled null identity blocks it.
|
|
205
|
+
function userMismatchBlocksCommit(state, attempt) {
|
|
206
|
+
return (state.user?.uid !== attempt.userUid &&
|
|
207
|
+
(!attempt.explicitUser || state.user !== null || state.identitySettled));
|
|
208
|
+
}
|
|
209
|
+
function joinerReuse(state, attempt) {
|
|
210
|
+
return attempt.joined &&
|
|
211
|
+
state.committedMint !== undefined &&
|
|
212
|
+
state.committedMint.mintId === attempt.mintId &&
|
|
213
|
+
state.credential === state.committedMint.session
|
|
214
|
+
? { verdict: 'reuse', session: state.committedMint.session }
|
|
215
|
+
: undefined;
|
|
216
|
+
}
|
|
217
|
+
export function arbitrateMint(state, attempt) {
|
|
218
|
+
if (supersededByInvalidation(state, attempt))
|
|
219
|
+
return SUPERSEDED;
|
|
220
|
+
const raced = lostRace(state, attempt);
|
|
221
|
+
if (raced)
|
|
222
|
+
return raced;
|
|
223
|
+
const stale = crossedIdentityEvent(state, attempt)
|
|
224
|
+
? !popupSettled(state, attempt)
|
|
225
|
+
: userMismatchBlocksCommit(state, attempt);
|
|
226
|
+
if (stale)
|
|
227
|
+
return SUPERSEDED;
|
|
228
|
+
return joinerReuse(state, attempt) ?? { verdict: 'commit' };
|
|
229
|
+
}
|
|
230
|
+
export function scheduledMintHolds(state, guards, userUid, mintId) {
|
|
231
|
+
return (mintId === state.mintSequence &&
|
|
232
|
+
state.user?.uid === userUid &&
|
|
233
|
+
state.identityEpoch === guards.epoch &&
|
|
234
|
+
state.invalidationEpoch === guards.invalidation);
|
|
235
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package-owned identity entry. Hosts either pass Firebase configuration
|
|
3
|
+
* — including the persistence choice — for an app the package initializes,
|
|
4
|
+
* or hand over the `Auth` they already hold, and get back the identity
|
|
5
|
+
* surface the session core binds to plus the sign-in actions.
|
|
6
|
+
*
|
|
7
|
+
* Popup, never `signInWithRedirect`: the redirect flow is broken under
|
|
8
|
+
* Safari's ITP for cross-origin helper domains, which is why the cloud app
|
|
9
|
+
* is popup-only too. Provider scopes and the `select_account` prompt are the
|
|
10
|
+
* cloud app's.
|
|
11
|
+
*
|
|
12
|
+
* This entry is the one place the package touches the Firebase SDK;
|
|
13
|
+
* importGuard.test.ts holds `./core` to that boundary.
|
|
14
|
+
*/
|
|
15
|
+
import type { FirebaseOptions } from 'firebase/app';
|
|
16
|
+
import type { Auth, Persistence, User, UserCredential } from 'firebase/auth';
|
|
17
|
+
import type { AccountIdentity } from '../core/identity.js';
|
|
18
|
+
export interface FirebaseIdentityAppConfig {
|
|
19
|
+
readonly options: FirebaseOptions;
|
|
20
|
+
/** Named app: never contend with a default app another script creates. */
|
|
21
|
+
readonly appName?: string;
|
|
22
|
+
/** Host-selected persistence; Firebase's default when omitted. */
|
|
23
|
+
readonly persistence?: Persistence;
|
|
24
|
+
/** A host-owned `Auth` and package-owned app options are exclusive. */
|
|
25
|
+
readonly auth?: never;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
export interface FirebaseIdentityAuthConfig {
|
|
32
|
+
readonly auth: Auth;
|
|
33
|
+
readonly options?: never;
|
|
34
|
+
readonly appName?: never;
|
|
35
|
+
readonly persistence?: never;
|
|
36
|
+
}
|
|
37
|
+
export type FirebaseIdentityConfig = FirebaseIdentityAppConfig | FirebaseIdentityAuthConfig;
|
|
38
|
+
export interface FirebaseIdentity extends AccountIdentity<User> {
|
|
39
|
+
/**
|
|
40
|
+
* Fires with the restored user (or null) once Firebase settles, then on
|
|
41
|
+
* every change. This is the identity the session core binds to.
|
|
42
|
+
*/
|
|
43
|
+
onUserChanged: (callback: (user: User | null) => void) => () => void;
|
|
44
|
+
signInWithGoogle: () => Promise<UserCredential>;
|
|
45
|
+
signInWithGitHub: () => Promise<UserCredential>;
|
|
46
|
+
signInWithEmail: (email: string, password: string) => Promise<UserCredential>;
|
|
47
|
+
createUserWithEmail: (email: string, password: string) => Promise<UserCredential>;
|
|
48
|
+
sendPasswordReset: (email: string) => Promise<void>;
|
|
49
|
+
/** For the signed-in user; rejects when nobody is signed in. */
|
|
50
|
+
updatePassword: (newPassword: string) => Promise<void>;
|
|
51
|
+
signOut: () => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export declare function createFirebaseIdentity(config: FirebaseIdentityConfig): FirebaseIdentity;
|
|
@@ -0,0 +1,74 @@
|
|
|
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';
|
|
4
|
+
import { isFirebaseAuthErrorLike } from '../firebaseAuthError.js';
|
|
5
|
+
function googleProvider() {
|
|
6
|
+
const provider = new GoogleAuthProvider();
|
|
7
|
+
provider.addScope('email');
|
|
8
|
+
provider.setCustomParameters({ prompt: 'select_account' });
|
|
9
|
+
return provider;
|
|
10
|
+
}
|
|
11
|
+
function githubProvider() {
|
|
12
|
+
const provider = new GithubAuthProvider();
|
|
13
|
+
provider.addScope('user:email');
|
|
14
|
+
provider.setCustomParameters({ prompt: 'select_account' });
|
|
15
|
+
return provider;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* An unknown email must look exactly like a sent reset, which is how Firebase
|
|
19
|
+
* itself answers with email enumeration protection on; a distinct failure
|
|
20
|
+
* here would be an account enumeration oracle.
|
|
21
|
+
*/
|
|
22
|
+
function resolveUnknownEmailAsSent(error) {
|
|
23
|
+
if (isFirebaseAuthErrorLike(error) && error.code === 'auth/user-not-found') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
function authResolver(config) {
|
|
35
|
+
if (config.auth) {
|
|
36
|
+
const { auth } = config;
|
|
37
|
+
return () => auth;
|
|
38
|
+
}
|
|
39
|
+
const appName = config.appName ?? 'comfy-account';
|
|
40
|
+
let resolved;
|
|
41
|
+
return () => {
|
|
42
|
+
if (resolved)
|
|
43
|
+
return resolved;
|
|
44
|
+
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 })
|
|
52
|
+
: getAuth(app);
|
|
53
|
+
return resolved;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function createFirebaseIdentity(config) {
|
|
57
|
+
const auth = authResolver(config);
|
|
58
|
+
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),
|
|
65
|
+
sendPasswordReset: (email) => sendPasswordResetEmail(auth(), email).catch(resolveUnknownEmailAsSent),
|
|
66
|
+
updatePassword: (newPassword) => {
|
|
67
|
+
const user = auth().currentUser;
|
|
68
|
+
return user
|
|
69
|
+
? updatePassword(user, newPassword)
|
|
70
|
+
: Promise.reject(new Error('No signed-in user to update the password for'));
|
|
71
|
+
},
|
|
72
|
+
signOut: () => signOut(auth())
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of Firebase Auth failures, shared so both hosts branch on
|
|
3
|
+
* the same buckets. Typed structurally rather than via `instanceof
|
|
4
|
+
* FirebaseError` so this package needs no firebase dependency — a host passes
|
|
5
|
+
* whatever it caught and unknown shapes land in 'unknown'.
|
|
6
|
+
*/
|
|
7
|
+
export interface FirebaseAuthErrorLike {
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
export type AuthErrorClassification = {
|
|
12
|
+
kind: 'unauthorized-domain';
|
|
13
|
+
code: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'signup-blocked';
|
|
16
|
+
code: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'popup-dismissed';
|
|
19
|
+
code: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'auth';
|
|
22
|
+
code: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'unknown';
|
|
25
|
+
};
|
|
26
|
+
export declare function isFirebaseAuthErrorLike(error: unknown): error is FirebaseAuthErrorLike;
|
|
27
|
+
export declare function classifyAuthError(error: unknown): AuthErrorClassification;
|
|
28
|
+
export type AuthToastSeverity = 'error' | 'warn';
|
|
29
|
+
/**
|
|
30
|
+
* The cloud app's toast-severity policy for classified auth failures: a
|
|
31
|
+
* dismissed popup is the user changing their mind, not an application
|
|
32
|
+
* error; everything else alarms.
|
|
33
|
+
*/
|
|
34
|
+
export declare function severityForAuthError(classification: AuthErrorClassification): AuthToastSeverity;
|
|
35
|
+
/**
|
|
36
|
+
* A host's auth error table: Firebase codes mapped to that host's copy, plus
|
|
37
|
+
* the two fallbacks every table must carry. Hosts build this from their own
|
|
38
|
+
* i18n; the package ships no strings of its own.
|
|
39
|
+
*/
|
|
40
|
+
export type AuthErrorCopy = Readonly<Record<string, string>> & {
|
|
41
|
+
readonly generic: string;
|
|
42
|
+
readonly signupBlocked: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The detail copy for a classified failure, resolved against the host copy:
|
|
46
|
+
* a code the table knows gets its own line, anything else the generic line, a
|
|
47
|
+
* blocked sign-up its named copy. The user-not-found / wrong-password pair
|
|
48
|
+
* collapses to the invalid-credential line so a sign-in attempt can never
|
|
49
|
+
* reveal whether an email has an account. Unauthorized domains need the
|
|
50
|
+
* host's domain and support address, which the host interpolates itself.
|
|
51
|
+
*/
|
|
52
|
+
export declare function authErrorMessage(classification: AuthErrorClassification, copy: AuthErrorCopy): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of Firebase Auth failures, shared so both hosts branch on
|
|
3
|
+
* the same buckets. Typed structurally rather than via `instanceof
|
|
4
|
+
* FirebaseError` so this package needs no firebase dependency — a host passes
|
|
5
|
+
* whatever it caught and unknown shapes land in 'unknown'.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The user or their browser dismissed/blocked the popup — an outcome to warn
|
|
9
|
+
* about and retry on a fresh gesture, never an app fault.
|
|
10
|
+
*/
|
|
11
|
+
const POPUP_DISMISSED_CODES = [
|
|
12
|
+
'auth/popup-closed-by-user',
|
|
13
|
+
'auth/cancelled-popup-request',
|
|
14
|
+
'auth/popup-blocked'
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* The origin is not on the Firebase authorized-domains list (or a continue
|
|
18
|
+
* URI is unauthorized): auth cannot work here at all until configuration
|
|
19
|
+
* changes, so the host should say so rather than offer a retry.
|
|
20
|
+
*/
|
|
21
|
+
const UNAUTHORIZED_DOMAIN_CODES = [
|
|
22
|
+
'auth/unauthorized-domain',
|
|
23
|
+
'auth/invalid-dynamic-link-domain',
|
|
24
|
+
'auth/unauthorized-continue-uri'
|
|
25
|
+
];
|
|
26
|
+
export function isFirebaseAuthErrorLike(error) {
|
|
27
|
+
return (typeof error === 'object' &&
|
|
28
|
+
error !== null &&
|
|
29
|
+
'code' in error &&
|
|
30
|
+
typeof error.code === 'string' &&
|
|
31
|
+
error.code.startsWith('auth/') &&
|
|
32
|
+
'message' in error &&
|
|
33
|
+
typeof error.message === 'string');
|
|
34
|
+
}
|
|
35
|
+
export function classifyAuthError(error) {
|
|
36
|
+
if (!isFirebaseAuthErrorLike(error))
|
|
37
|
+
return { kind: 'unknown' };
|
|
38
|
+
if (UNAUTHORIZED_DOMAIN_CODES.includes(error.code)) {
|
|
39
|
+
return { kind: 'unauthorized-domain', code: error.code };
|
|
40
|
+
}
|
|
41
|
+
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
|
|
42
|
+
// rejections collapse the thrown code into a generic `auth/internal-error`,
|
|
43
|
+
// so the message is the only reliable channel. `signup_blocked` is a
|
|
44
|
+
// cross-repo contract token; matched case-insensitively.
|
|
45
|
+
if (error.message.toLowerCase().includes('signup_blocked')) {
|
|
46
|
+
return { kind: 'signup-blocked', code: error.code };
|
|
47
|
+
}
|
|
48
|
+
if (POPUP_DISMISSED_CODES.includes(error.code)) {
|
|
49
|
+
return { kind: 'popup-dismissed', code: error.code };
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'auth', code: error.code };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The cloud app's toast-severity policy for classified auth failures: a
|
|
55
|
+
* dismissed popup is the user changing their mind, not an application
|
|
56
|
+
* error; everything else alarms.
|
|
57
|
+
*/
|
|
58
|
+
export function severityForAuthError(classification) {
|
|
59
|
+
return classification.kind === 'popup-dismissed' ? 'warn' : 'error';
|
|
60
|
+
}
|
|
61
|
+
/** Resolved to the invalid-credential line whatever table is in play. */
|
|
62
|
+
const ENUMERATION_NEUTRAL_CODES = new Set([
|
|
63
|
+
'auth/user-not-found',
|
|
64
|
+
'auth/wrong-password'
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* The detail copy for a classified failure, resolved against the host copy:
|
|
68
|
+
* a code the table knows gets its own line, anything else the generic line, a
|
|
69
|
+
* blocked sign-up its named copy. The user-not-found / wrong-password pair
|
|
70
|
+
* collapses to the invalid-credential line so a sign-in attempt can never
|
|
71
|
+
* reveal whether an email has an account. Unauthorized domains need the
|
|
72
|
+
* host's domain and support address, which the host interpolates itself.
|
|
73
|
+
*/
|
|
74
|
+
export function authErrorMessage(classification, copy) {
|
|
75
|
+
switch (classification.kind) {
|
|
76
|
+
case 'signup-blocked':
|
|
77
|
+
return copy.signupBlocked;
|
|
78
|
+
case 'popup-dismissed':
|
|
79
|
+
case 'auth':
|
|
80
|
+
if (ENUMERATION_NEUTRAL_CODES.has(classification.code)) {
|
|
81
|
+
return copy['auth/invalid-credential'] ?? copy.generic;
|
|
82
|
+
}
|
|
83
|
+
return copy[classification.code] ?? copy.generic;
|
|
84
|
+
case 'unauthorized-domain':
|
|
85
|
+
case 'unknown':
|
|
86
|
+
return copy.generic;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a singleton loader for an external script. `getReady` should return
|
|
3
|
+
* the resolved value when the script's global is available, or `null` when not.
|
|
4
|
+
* The returned function caches the in-flight Promise so concurrent callers share
|
|
5
|
+
* one load, and resets on failure so a later caller can retry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createScriptLoader<T>(src: string, getReady: () => T | null, timeoutMs?: number): () => Promise<T>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Keep in sync with packages/shared-frontend-utils/src/loadExternalScript.ts. */
|
|
2
|
+
const POLL_INTERVAL_MS = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Returns a singleton loader for an external script. `getReady` should return
|
|
5
|
+
* the resolved value when the script's global is available, or `null` when not.
|
|
6
|
+
* The returned function caches the in-flight Promise so concurrent callers share
|
|
7
|
+
* one load, and resets on failure so a later caller can retry.
|
|
8
|
+
*/
|
|
9
|
+
export function createScriptLoader(src, getReady, timeoutMs = 10_000) {
|
|
10
|
+
let scriptPromise = null;
|
|
11
|
+
return function loadScript() {
|
|
12
|
+
if (scriptPromise)
|
|
13
|
+
return scriptPromise;
|
|
14
|
+
scriptPromise = new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
let cancelPoll;
|
|
17
|
+
function trySettle(fn) {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
fn();
|
|
22
|
+
}
|
|
23
|
+
const ready = getReady();
|
|
24
|
+
if (ready !== null) {
|
|
25
|
+
resolve(ready);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
29
|
+
function startPoll(onSuccess) {
|
|
30
|
+
const pollId = window.setInterval(() => {
|
|
31
|
+
const value = getReady();
|
|
32
|
+
if (value !== null) {
|
|
33
|
+
window.clearInterval(pollId);
|
|
34
|
+
onSuccess();
|
|
35
|
+
trySettle(() => resolve(value));
|
|
36
|
+
}
|
|
37
|
+
}, POLL_INTERVAL_MS);
|
|
38
|
+
return () => window.clearInterval(pollId);
|
|
39
|
+
}
|
|
40
|
+
if (existing) {
|
|
41
|
+
// load/error may have already fired on the pre-existing tag; poll for readiness
|
|
42
|
+
const timeoutId = window.setTimeout(() => {
|
|
43
|
+
cancelPoll?.();
|
|
44
|
+
trySettle(() => {
|
|
45
|
+
scriptPromise = null;
|
|
46
|
+
reject(new Error(`Script load timed out: ${src}`));
|
|
47
|
+
});
|
|
48
|
+
}, timeoutMs);
|
|
49
|
+
cancelPoll = startPoll(() => window.clearTimeout(timeoutId));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const scriptEl = document.createElement('script');
|
|
53
|
+
const timeoutId = window.setTimeout(() => {
|
|
54
|
+
cancelPoll?.();
|
|
55
|
+
scriptEl.remove();
|
|
56
|
+
trySettle(() => {
|
|
57
|
+
scriptPromise = null;
|
|
58
|
+
reject(new Error(`Script load timed out: ${src}`));
|
|
59
|
+
});
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
scriptEl.addEventListener('load', () => {
|
|
62
|
+
if (settled)
|
|
63
|
+
return;
|
|
64
|
+
const value = getReady();
|
|
65
|
+
if (value !== null) {
|
|
66
|
+
window.clearTimeout(timeoutId);
|
|
67
|
+
trySettle(() => resolve(value));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// global may arrive asynchronously after load; poll under the shared timeout
|
|
71
|
+
cancelPoll = startPoll(() => window.clearTimeout(timeoutId));
|
|
72
|
+
}
|
|
73
|
+
}, { once: true });
|
|
74
|
+
scriptEl.addEventListener('error', () => {
|
|
75
|
+
window.clearTimeout(timeoutId);
|
|
76
|
+
scriptEl.remove();
|
|
77
|
+
trySettle(() => {
|
|
78
|
+
scriptPromise = null;
|
|
79
|
+
reject(new Error(`Script failed to load: ${src}`));
|
|
80
|
+
});
|
|
81
|
+
}, { once: true });
|
|
82
|
+
scriptEl.src = src;
|
|
83
|
+
scriptEl.async = true;
|
|
84
|
+
document.head.appendChild(scriptEl);
|
|
85
|
+
});
|
|
86
|
+
return scriptPromise;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account-creation sequences both hosts must agree on, with the effects
|
|
3
|
+
* injected so the invariants live (and are tested) exactly once. Mirrors the
|
|
4
|
+
* platform app's `authStore.register` / `loginWithGoogle` semantics.
|
|
5
|
+
*/
|
|
6
|
+
interface DeletableUserCredential {
|
|
7
|
+
user: {
|
|
8
|
+
delete: () => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Email sign-up: create the Firebase user, then provision the Comfy customer.
|
|
13
|
+
*
|
|
14
|
+
* Provisioning is where a Turnstile token gets validated server-side; if it
|
|
15
|
+
* fails (rejection, 5xx, network) the Firebase user is already created and,
|
|
16
|
+
* without rollback, the account is orphaned — every retry then fails
|
|
17
|
+
* "email already in use", permanently bricking signup. So a provisioning
|
|
18
|
+
* failure deletes the just-created user (best-effort with one retried
|
|
19
|
+
* delete, so a single transient blip cannot orphan the account; a cleanup
|
|
20
|
+
* failure never masks the original error) and rethrows.
|
|
21
|
+
*/
|
|
22
|
+
export declare function signUpWithProvisioning<T extends DeletableUserCredential>(deps: {
|
|
23
|
+
createUser: () => Promise<T>;
|
|
24
|
+
provisionCustomer: (credential: T) => Promise<unknown>;
|
|
25
|
+
onRollbackFailure?: (error: unknown) => void;
|
|
26
|
+
}): Promise<T>;
|
|
27
|
+
/**
|
|
28
|
+
* Social sign-in: the popup, then find-or-create the Comfy customer — always,
|
|
29
|
+
* because a social user's first touch may be this host and skipping the
|
|
30
|
+
* customer record produces accounts where billing surfaces fail.
|
|
31
|
+
* `provisionCustomer` must tolerate an already-provisioned account. No
|
|
32
|
+
* rollback here: the signed-in user may be a long-standing account, and
|
|
33
|
+
* deleting it over a provisioning hiccup would destroy it — a provisioning
|
|
34
|
+
* failure propagates with the user still signed in.
|
|
35
|
+
*/
|
|
36
|
+
export declare function socialSignInWithProvisioning<T>(deps: {
|
|
37
|
+
signIn: () => Promise<T>;
|
|
38
|
+
provisionCustomer: (credential: T) => Promise<unknown>;
|
|
39
|
+
}): Promise<T>;
|
|
40
|
+
export declare const CUSTOMER_PROVISIONING_PATH = "/customers";
|
|
41
|
+
/**
|
|
42
|
+
* The `POST /customers` request both hosts send. `signupSource` names the
|
|
43
|
+
* host for the backend's attribution; the Turnstile token rides along only
|
|
44
|
+
* on email sign-up, where the server validates it. Body and headers only: a
|
|
45
|
+
* host that wants a bound passes its own `signal`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function customerProvisioningRequest(options: {
|
|
48
|
+
authHeaders: Record<string, string>;
|
|
49
|
+
signupSource: string;
|
|
50
|
+
turnstileToken?: string;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): RequestInit;
|
|
53
|
+
export {};
|