@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/billing/balanceWatch.d.ts +35 -0
  3. package/dist/core/billing/balanceWatch.js +83 -0
  4. package/dist/core/billing/billingContracts.d.ts +123 -0
  5. package/dist/core/billing/billingContracts.js +11 -0
  6. package/dist/core/billing/billingErrorBody.d.ts +8 -0
  7. package/dist/core/billing/billingErrorBody.js +11 -0
  8. package/dist/core/billing/billingScope.d.ts +39 -0
  9. package/dist/core/billing/billingScope.js +45 -0
  10. package/dist/core/billing/capabilities.d.ts +70 -0
  11. package/dist/core/billing/capabilities.js +137 -0
  12. package/dist/core/billing/capabilityDenials.d.ts +26 -0
  13. package/dist/core/billing/capabilityDenials.js +43 -0
  14. package/dist/core/billing/challengeDriver.d.ts +22 -0
  15. package/dist/core/billing/challengeDriver.js +38 -0
  16. package/dist/core/billing/credentialedTransport.d.ts +35 -0
  17. package/dist/core/billing/credentialedTransport.js +53 -0
  18. package/dist/core/billing/credits.d.ts +43 -0
  19. package/dist/core/billing/credits.js +29 -0
  20. package/dist/core/billing/httpStatus.d.ts +7 -0
  21. package/dist/core/billing/httpStatus.js +25 -0
  22. package/dist/core/billing/index.d.ts +52 -0
  23. package/dist/core/billing/index.js +23 -0
  24. package/dist/core/billing/operationLifecycle.d.ts +77 -0
  25. package/dist/core/billing/operationLifecycle.js +547 -0
  26. package/dist/core/billing/operationPointer.d.ts +48 -0
  27. package/dist/core/billing/operationPointer.js +89 -0
  28. package/dist/core/billing/operationPolicy.d.ts +26 -0
  29. package/dist/core/billing/operationPolicy.js +51 -0
  30. package/dist/core/billing/operationState.d.ts +128 -0
  31. package/dist/core/billing/operationState.js +187 -0
  32. package/dist/core/billing/paymentCopy.d.ts +18 -0
  33. package/dist/core/billing/paymentCopy.js +47 -0
  34. package/dist/core/billing/paymentMethods.d.ts +40 -0
  35. package/dist/core/billing/paymentMethods.js +37 -0
  36. package/dist/core/billing/paymentProjection.d.ts +34 -0
  37. package/dist/core/billing/paymentProjection.js +56 -0
  38. package/dist/core/billing/plans.d.ts +44 -0
  39. package/dist/core/billing/plans.js +29 -0
  40. package/dist/core/billing/presentation.d.ts +20 -0
  41. package/dist/core/billing/presentation.js +10 -0
  42. package/dist/core/billing/scopedReader.d.ts +61 -0
  43. package/dist/core/billing/scopedReader.js +108 -0
  44. package/dist/core/billing/sharedRead.d.ts +33 -0
  45. package/dist/core/billing/sharedRead.js +59 -0
  46. package/dist/core/billing/status.d.ts +27 -0
  47. package/dist/core/billing/status.js +21 -0
  48. package/dist/core/billing/subscriptionCommands.d.ts +367 -0
  49. package/dist/core/billing/subscriptionCommands.js +263 -0
  50. package/dist/core/billing/topup.d.ts +113 -0
  51. package/dist/core/billing/topup.js +209 -0
  52. package/dist/core/billing/transport.d.ts +14 -0
  53. package/dist/core/billing/transport.js +106 -0
  54. package/dist/core/billing/transportExchange.d.ts +24 -0
  55. package/dist/core/billing/transportExchange.js +79 -0
  56. package/dist/core/boundedOperation.d.ts +16 -0
  57. package/dist/core/boundedOperation.js +12 -0
  58. package/dist/core/credentialCache.d.ts +41 -0
  59. package/dist/core/credentialCache.js +97 -0
  60. package/dist/core/customerRecovery.d.ts +39 -0
  61. package/dist/core/customerRecovery.js +79 -0
  62. package/dist/core/exchange.d.ts +50 -0
  63. package/dist/core/exchange.js +137 -0
  64. package/dist/core/identity.d.ts +21 -0
  65. package/dist/core/identity.js +16 -0
  66. package/dist/core/mintCoordinator.d.ts +18 -0
  67. package/dist/core/mintCoordinator.js +31 -0
  68. package/dist/core/refreshScheduler.d.ts +46 -0
  69. package/dist/core/refreshScheduler.js +262 -0
  70. package/dist/core/session.d.ts +146 -0
  71. package/dist/core/session.js +312 -0
  72. package/dist/core/sessionContracts.d.ts +135 -0
  73. package/dist/core/sessionContracts.js +9 -0
  74. package/dist/core/sessionState.d.ts +118 -0
  75. package/dist/core/sessionState.js +235 -0
  76. package/dist/firebase/index.d.ts +53 -0
  77. package/dist/firebase/index.js +74 -0
  78. package/dist/firebaseAuthError.d.ts +52 -0
  79. package/dist/firebaseAuthError.js +88 -0
  80. package/dist/loadExternalScript.d.ts +7 -0
  81. package/dist/loadExternalScript.js +88 -0
  82. package/dist/provisioning.d.ts +53 -0
  83. package/dist/provisioning.js +76 -0
  84. package/dist/redirect.d.ts +13 -0
  85. package/dist/redirect.js +38 -0
  86. package/dist/signInSchemas.d.ts +88 -0
  87. package/dist/signInSchemas.js +71 -0
  88. package/dist/telemetry.d.ts +39 -0
  89. package/dist/telemetry.js +26 -0
  90. package/dist/testing.d.ts +7 -0
  91. package/dist/testing.js +3 -0
  92. package/dist/turnstile.d.ts +14 -0
  93. package/dist/turnstile.js +17 -0
  94. package/dist/turnstileScript.d.ts +18 -0
  95. package/dist/turnstileScript.js +6 -0
  96. package/dist/web/crossTabRefresh.d.ts +18 -0
  97. package/dist/web/crossTabRefresh.js +121 -0
  98. package/dist/webviewDetection.d.ts +5 -0
  99. package/dist/webviewDetection.js +62 -0
  100. 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 {};