@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,76 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Email sign-up: create the Firebase user, then provision the Comfy customer.
|
|
8
|
+
*
|
|
9
|
+
* Provisioning is where a Turnstile token gets validated server-side; if it
|
|
10
|
+
* fails (rejection, 5xx, network) the Firebase user is already created and,
|
|
11
|
+
* without rollback, the account is orphaned — every retry then fails
|
|
12
|
+
* "email already in use", permanently bricking signup. So a provisioning
|
|
13
|
+
* failure deletes the just-created user (best-effort with one retried
|
|
14
|
+
* delete, so a single transient blip cannot orphan the account; a cleanup
|
|
15
|
+
* failure never masks the original error) and rethrows.
|
|
16
|
+
*/
|
|
17
|
+
export async function signUpWithProvisioning(deps) {
|
|
18
|
+
const credential = await deps.createUser();
|
|
19
|
+
try {
|
|
20
|
+
await deps.provisionCustomer(credential);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
try {
|
|
24
|
+
try {
|
|
25
|
+
await credential.user.delete();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
await credential.user.delete();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (rollbackError) {
|
|
32
|
+
// A reporting sink can throw; never let it displace the original error.
|
|
33
|
+
try {
|
|
34
|
+
deps.onRollbackFailure?.(rollbackError);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
void 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return credential;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Social sign-in: the popup, then find-or-create the Comfy customer — always,
|
|
46
|
+
* because a social user's first touch may be this host and skipping the
|
|
47
|
+
* customer record produces accounts where billing surfaces fail.
|
|
48
|
+
* `provisionCustomer` must tolerate an already-provisioned account. No
|
|
49
|
+
* rollback here: the signed-in user may be a long-standing account, and
|
|
50
|
+
* deleting it over a provisioning hiccup would destroy it — a provisioning
|
|
51
|
+
* failure propagates with the user still signed in.
|
|
52
|
+
*/
|
|
53
|
+
export async function socialSignInWithProvisioning(deps) {
|
|
54
|
+
const credential = await deps.signIn();
|
|
55
|
+
await deps.provisionCustomer(credential);
|
|
56
|
+
return credential;
|
|
57
|
+
}
|
|
58
|
+
export const CUSTOMER_PROVISIONING_PATH = '/customers';
|
|
59
|
+
/**
|
|
60
|
+
* The `POST /customers` request both hosts send. `signupSource` names the
|
|
61
|
+
* host for the backend's attribution; the Turnstile token rides along only
|
|
62
|
+
* on email sign-up, where the server validates it. Body and headers only: a
|
|
63
|
+
* host that wants a bound passes its own `signal`.
|
|
64
|
+
*/
|
|
65
|
+
export function customerProvisioningRequest(options) {
|
|
66
|
+
const { authHeaders, signupSource, turnstileToken, signal } = options;
|
|
67
|
+
return {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
signup_source: signupSource,
|
|
72
|
+
...(turnstileToken ? { turnstile_token: turnstileToken } : {})
|
|
73
|
+
}),
|
|
74
|
+
...(signal ? { signal } : {})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a visitor may be sent after sign-in. Only a same-origin absolute
|
|
3
|
+
* path qualifies; anything that could leave the origin (a protocol-relative
|
|
4
|
+
* or backslash prefix, an absolute or javascript: URL, or a path hiding one
|
|
5
|
+
* of those behind a stripped control char) falls back.
|
|
6
|
+
*
|
|
7
|
+
* The prefix checks alone are not enough: the WHATWG URL parser strips C0
|
|
8
|
+
* control chars (tab, LF, CR) before parsing, so `/<TAB>//evil.com` passes a
|
|
9
|
+
* literal `startsWith('//')` check yet the browser resolves it cross-origin.
|
|
10
|
+
* So a control char is rejected outright, and the result is re-parsed against
|
|
11
|
+
* the origin to confirm it truly stays same-origin.
|
|
12
|
+
*/
|
|
13
|
+
export declare function safeInternalPath(raw: string | null | undefined, origin: string, fallback: string): string;
|
package/dist/redirect.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** True if the string contains any C0 control char the URL parser would strip. */
|
|
2
|
+
function hasControlChar(value) {
|
|
3
|
+
for (let i = 0; i < value.length; i++) {
|
|
4
|
+
if (value.charCodeAt(i) <= 0x1f)
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where a visitor may be sent after sign-in. Only a same-origin absolute
|
|
11
|
+
* path qualifies; anything that could leave the origin (a protocol-relative
|
|
12
|
+
* or backslash prefix, an absolute or javascript: URL, or a path hiding one
|
|
13
|
+
* of those behind a stripped control char) falls back.
|
|
14
|
+
*
|
|
15
|
+
* The prefix checks alone are not enough: the WHATWG URL parser strips C0
|
|
16
|
+
* control chars (tab, LF, CR) before parsing, so `/<TAB>//evil.com` passes a
|
|
17
|
+
* literal `startsWith('//')` check yet the browser resolves it cross-origin.
|
|
18
|
+
* So a control char is rejected outright, and the result is re-parsed against
|
|
19
|
+
* the origin to confirm it truly stays same-origin.
|
|
20
|
+
*/
|
|
21
|
+
export function safeInternalPath(raw, origin, fallback) {
|
|
22
|
+
if (typeof raw !== 'string' ||
|
|
23
|
+
!raw.startsWith('/') ||
|
|
24
|
+
raw.startsWith('//') ||
|
|
25
|
+
raw.startsWith('/\\') ||
|
|
26
|
+
hasControlChar(raw)) {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const resolved = new URL(raw, origin);
|
|
31
|
+
if (resolved.origin !== origin)
|
|
32
|
+
return fallback;
|
|
33
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The exact message keys the schemas emit, so a host wires every one of them
|
|
4
|
+
* or the compiler says which is missing. Both hosts already carry these keys:
|
|
5
|
+
* the app in `src/locales/en/main.json`, the website in its translations map.
|
|
6
|
+
*/
|
|
7
|
+
export type AuthSchemaMessageKey = 'validation.prefix' | 'validation.length' | 'validation.invalidEmail' | 'validation.required' | 'validation.minLength' | 'validation.maxLength' | 'validation.password.uppercase' | 'validation.password.lowercase' | 'validation.password.number' | 'validation.password.special' | 'validation.password.match';
|
|
8
|
+
export type AuthSchemaTranslate = (key: AuthSchemaMessageKey, params?: Record<string, string | number>) => string;
|
|
9
|
+
/**
|
|
10
|
+
* The one password policy, shared by the schema and by the live checklist
|
|
11
|
+
* sign-up forms show while the field is being typed.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PASSWORD_RULES: {
|
|
14
|
+
readonly minLength: 8;
|
|
15
|
+
readonly maxLength: 32;
|
|
16
|
+
readonly uppercase: RegExp;
|
|
17
|
+
readonly lowercase: RegExp;
|
|
18
|
+
readonly number: RegExp;
|
|
19
|
+
readonly special: RegExp;
|
|
20
|
+
};
|
|
21
|
+
export type PasswordRule = 'length' | 'uppercase' | 'lowercase' | 'number' | 'special';
|
|
22
|
+
export declare function passwordRuleChecks(password: string): Readonly<Record<PasswordRule, boolean>>;
|
|
23
|
+
/**
|
|
24
|
+
* Builds the auth validation schemas with the host's own translator, so the
|
|
25
|
+
* rules live once while each host keeps its i18n system. Messages resolve
|
|
26
|
+
* eagerly at build time, so a locale switch requires rebuilding the schemas.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createAuthSchemas(t: AuthSchemaTranslate): {
|
|
29
|
+
apiKeySchema: z.ZodObject<{
|
|
30
|
+
apiKey: z.ZodString;
|
|
31
|
+
}, "strip", z.ZodTypeAny, {
|
|
32
|
+
apiKey: string;
|
|
33
|
+
}, {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
}>;
|
|
36
|
+
signInSchema: z.ZodObject<{
|
|
37
|
+
email: z.ZodString;
|
|
38
|
+
password: z.ZodString;
|
|
39
|
+
}, "strip", z.ZodTypeAny, {
|
|
40
|
+
email: string;
|
|
41
|
+
password: string;
|
|
42
|
+
}, {
|
|
43
|
+
email: string;
|
|
44
|
+
password: string;
|
|
45
|
+
}>;
|
|
46
|
+
updatePasswordSchema: z.ZodEffects<z.ZodObject<{
|
|
47
|
+
password: z.ZodString;
|
|
48
|
+
confirmPassword: z.ZodString;
|
|
49
|
+
}, "strip", z.ZodTypeAny, {
|
|
50
|
+
password: string;
|
|
51
|
+
confirmPassword: string;
|
|
52
|
+
}, {
|
|
53
|
+
password: string;
|
|
54
|
+
confirmPassword: string;
|
|
55
|
+
}>, {
|
|
56
|
+
password: string;
|
|
57
|
+
confirmPassword: string;
|
|
58
|
+
}, {
|
|
59
|
+
password: string;
|
|
60
|
+
confirmPassword: string;
|
|
61
|
+
}>;
|
|
62
|
+
signUpSchema: z.ZodEffects<z.ZodObject<{
|
|
63
|
+
password: z.ZodString;
|
|
64
|
+
confirmPassword: z.ZodString;
|
|
65
|
+
} & {
|
|
66
|
+
email: z.ZodString;
|
|
67
|
+
}, "strip", z.ZodTypeAny, {
|
|
68
|
+
email: string;
|
|
69
|
+
password: string;
|
|
70
|
+
confirmPassword: string;
|
|
71
|
+
}, {
|
|
72
|
+
email: string;
|
|
73
|
+
password: string;
|
|
74
|
+
confirmPassword: string;
|
|
75
|
+
}>, {
|
|
76
|
+
email: string;
|
|
77
|
+
password: string;
|
|
78
|
+
confirmPassword: string;
|
|
79
|
+
}, {
|
|
80
|
+
email: string;
|
|
81
|
+
password: string;
|
|
82
|
+
confirmPassword: string;
|
|
83
|
+
}>;
|
|
84
|
+
};
|
|
85
|
+
type AuthSchemas = ReturnType<typeof createAuthSchemas>;
|
|
86
|
+
export type SignInData = z.infer<AuthSchemas['signInSchema']>;
|
|
87
|
+
export type SignUpData = z.infer<AuthSchemas['signUpSchema']>;
|
|
88
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The one password policy, shared by the schema and by the live checklist
|
|
4
|
+
* sign-up forms show while the field is being typed.
|
|
5
|
+
*/
|
|
6
|
+
export const PASSWORD_RULES = {
|
|
7
|
+
minLength: 8,
|
|
8
|
+
maxLength: 32,
|
|
9
|
+
uppercase: /[A-Z]/,
|
|
10
|
+
lowercase: /[a-z]/,
|
|
11
|
+
number: /\d/,
|
|
12
|
+
special: /[^A-Za-z0-9]/
|
|
13
|
+
};
|
|
14
|
+
export function passwordRuleChecks(password) {
|
|
15
|
+
return {
|
|
16
|
+
length: password.length >= PASSWORD_RULES.minLength &&
|
|
17
|
+
password.length <= PASSWORD_RULES.maxLength,
|
|
18
|
+
uppercase: PASSWORD_RULES.uppercase.test(password),
|
|
19
|
+
lowercase: PASSWORD_RULES.lowercase.test(password),
|
|
20
|
+
number: PASSWORD_RULES.number.test(password),
|
|
21
|
+
special: PASSWORD_RULES.special.test(password)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Builds the auth validation schemas with the host's own translator, so the
|
|
26
|
+
* rules live once while each host keeps its i18n system. Messages resolve
|
|
27
|
+
* eagerly at build time, so a locale switch requires rebuilding the schemas.
|
|
28
|
+
*/
|
|
29
|
+
export function createAuthSchemas(t) {
|
|
30
|
+
const apiKeySchema = z.object({
|
|
31
|
+
apiKey: z
|
|
32
|
+
.string()
|
|
33
|
+
.trim()
|
|
34
|
+
.startsWith('comfyui-', t('validation.prefix', { prefix: 'comfyui-' }))
|
|
35
|
+
.length(72, t('validation.length', { length: 72 }))
|
|
36
|
+
});
|
|
37
|
+
const signInSchema = z.object({
|
|
38
|
+
email: z
|
|
39
|
+
.string()
|
|
40
|
+
.email(t('validation.invalidEmail'))
|
|
41
|
+
.min(1, t('validation.required')),
|
|
42
|
+
password: z.string().min(1, t('validation.required'))
|
|
43
|
+
});
|
|
44
|
+
const passwordSchema = z.object({
|
|
45
|
+
password: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(PASSWORD_RULES.minLength, t('validation.minLength', { length: PASSWORD_RULES.minLength }))
|
|
48
|
+
.max(PASSWORD_RULES.maxLength, t('validation.maxLength', { length: PASSWORD_RULES.maxLength }))
|
|
49
|
+
.regex(PASSWORD_RULES.uppercase, t('validation.password.uppercase'))
|
|
50
|
+
.regex(PASSWORD_RULES.lowercase, t('validation.password.lowercase'))
|
|
51
|
+
.regex(PASSWORD_RULES.number, t('validation.password.number'))
|
|
52
|
+
.regex(PASSWORD_RULES.special, t('validation.password.special')),
|
|
53
|
+
confirmPassword: z.string().min(1, t('validation.required'))
|
|
54
|
+
});
|
|
55
|
+
const updatePasswordSchema = passwordSchema.refine((data) => data.password === data.confirmPassword, {
|
|
56
|
+
message: t('validation.password.match'),
|
|
57
|
+
path: ['confirmPassword']
|
|
58
|
+
});
|
|
59
|
+
const signUpSchema = passwordSchema
|
|
60
|
+
.extend({
|
|
61
|
+
email: z
|
|
62
|
+
.string()
|
|
63
|
+
.email(t('validation.invalidEmail'))
|
|
64
|
+
.min(1, t('validation.required'))
|
|
65
|
+
})
|
|
66
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
67
|
+
message: t('validation.password.match'),
|
|
68
|
+
path: ['confirmPassword']
|
|
69
|
+
});
|
|
70
|
+
return { apiKeySchema, signInSchema, updatePasswordSchema, signUpSchema };
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared telemetry vocabulary. The event names match the cloud app's
|
|
3
|
+
* TelemetryEvents verbatim so an auth outcome is one queryable event across
|
|
4
|
+
* every host. The package never calls a telemetry API itself; call sites
|
|
5
|
+
* stay host-specific.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SESSION_TELEMETRY_EVENT: {
|
|
8
|
+
readonly refreshSucceeded: "auth.unified.refresh.succeeded";
|
|
9
|
+
readonly refreshFailed: "auth.unified.refresh.failed";
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The cloud app's `billing.operation.*` events, emitted by the billing
|
|
13
|
+
* operation lifecycle for every observed `billing_op_id` regardless of
|
|
14
|
+
* which presentation settled it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const BILLING_OPERATION_TELEMETRY_EVENT: {
|
|
17
|
+
readonly started: "billing.operation.started";
|
|
18
|
+
readonly succeeded: "billing.operation.succeeded";
|
|
19
|
+
readonly failed: "billing.operation.failed";
|
|
20
|
+
readonly timeout: "billing.operation.timeout";
|
|
21
|
+
};
|
|
22
|
+
export declare const AUTH_TELEMETRY_EVENT: {
|
|
23
|
+
readonly signUpOpened: "app:user_sign_up_opened";
|
|
24
|
+
readonly authCompleted: "app:user_auth_completed";
|
|
25
|
+
readonly authFailed: "app:user_auth_failed";
|
|
26
|
+
};
|
|
27
|
+
export type AuthMethod = 'email' | 'google' | 'github';
|
|
28
|
+
/** What the cloud app reports on every successful credential. */
|
|
29
|
+
export interface AuthCompletedMetadata {
|
|
30
|
+
method: AuthMethod;
|
|
31
|
+
is_new_user: boolean;
|
|
32
|
+
user_id: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
}
|
|
35
|
+
export type AuthFlowAction = 'email_sign_in' | 'email_sign_up' | 'google_sign_in' | 'google_sign_up' | 'github_sign_in' | 'github_sign_up' | 'password_reset';
|
|
36
|
+
export interface AuthErrorMetadata {
|
|
37
|
+
error_code: string;
|
|
38
|
+
auth_action: AuthFlowAction;
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared telemetry vocabulary. The event names match the cloud app's
|
|
3
|
+
* TelemetryEvents verbatim so an auth outcome is one queryable event across
|
|
4
|
+
* every host. The package never calls a telemetry API itself; call sites
|
|
5
|
+
* stay host-specific.
|
|
6
|
+
*/
|
|
7
|
+
export const SESSION_TELEMETRY_EVENT = {
|
|
8
|
+
refreshSucceeded: 'auth.unified.refresh.succeeded',
|
|
9
|
+
refreshFailed: 'auth.unified.refresh.failed'
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The cloud app's `billing.operation.*` events, emitted by the billing
|
|
13
|
+
* operation lifecycle for every observed `billing_op_id` regardless of
|
|
14
|
+
* which presentation settled it.
|
|
15
|
+
*/
|
|
16
|
+
export const BILLING_OPERATION_TELEMETRY_EVENT = {
|
|
17
|
+
started: 'billing.operation.started',
|
|
18
|
+
succeeded: 'billing.operation.succeeded',
|
|
19
|
+
failed: 'billing.operation.failed',
|
|
20
|
+
timeout: 'billing.operation.timeout'
|
|
21
|
+
};
|
|
22
|
+
export const AUTH_TELEMETRY_EVENT = {
|
|
23
|
+
signUpOpened: 'app:user_sign_up_opened',
|
|
24
|
+
authCompleted: 'app:user_auth_completed',
|
|
25
|
+
authFailed: 'app:user_auth_failed'
|
|
26
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test seam for fakes: a hand-written port becomes an identity the
|
|
3
|
+
* session client accepts. Production hosts use `@comfyorg/account-core/firebase`.
|
|
4
|
+
*/
|
|
5
|
+
export type { IdentityPort } from './core/identity.js';
|
|
6
|
+
export { brandIdentity as createTestIdentity } from './core/identity.js';
|
|
7
|
+
export declare const ENUMERATION_ORACLE: RegExp;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { brandIdentity as createTestIdentity } from './core/identity.js';
|
|
2
|
+
// Enumerating wording (CWE-204) each host asserts its copy never matches; shared so the two suites can't drift.
|
|
3
|
+
export const ENUMERATION_ORACLE = /\bexists?\b|already (?:registered|in use|have|exists)|wrong password|no account|not found|is registered|different (?:sign-in method|credential)/i;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type TurnstileMode = 'off' | 'shadow' | 'enforce';
|
|
2
|
+
/**
|
|
3
|
+
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
|
|
4
|
+
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
|
|
5
|
+
* the widget rendered-but-unenforced — mirrors the server-side resolver.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeTurnstileMode(raw: string | undefined): TurnstileMode;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the signup Turnstile widget should render. Purely config-driven: the
|
|
10
|
+
* flag must be shadow/enforce and a sitekey must be configured. Hosts that
|
|
11
|
+
* resolve no sitekey (OSS / local builds) never render the widget; their
|
|
12
|
+
* exemption lives server-side (loopback-IP check in CreateCustomer).
|
|
13
|
+
*/
|
|
14
|
+
export declare function isTurnstileEnabled(mode: TurnstileMode, siteKey: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
|
|
3
|
+
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
|
|
4
|
+
* the widget rendered-but-unenforced — mirrors the server-side resolver.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeTurnstileMode(raw) {
|
|
7
|
+
return raw === 'shadow' || raw === 'enforce' ? raw : 'off';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Whether the signup Turnstile widget should render. Purely config-driven: the
|
|
11
|
+
* flag must be shadow/enforce and a sitekey must be configured. Hosts that
|
|
12
|
+
* resolve no sitekey (OSS / local builds) never render the widget; their
|
|
13
|
+
* exemption lives server-side (loopback-IP check in CreateCustomer).
|
|
14
|
+
*/
|
|
15
|
+
export function isTurnstileEnabled(mode, siteKey) {
|
|
16
|
+
return mode !== 'off' && siteKey !== '';
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface TurnstileRenderOptions {
|
|
2
|
+
sitekey: string;
|
|
3
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
4
|
+
callback?: (token: string) => void;
|
|
5
|
+
'expired-callback'?: () => void;
|
|
6
|
+
'error-callback'?: () => void;
|
|
7
|
+
}
|
|
8
|
+
export interface TurnstileApi {
|
|
9
|
+
render: (container: string | HTMLElement, options: TurnstileRenderOptions) => string;
|
|
10
|
+
reset: (widgetId?: string) => void;
|
|
11
|
+
remove: (widgetId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
turnstile?: TurnstileApi;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export declare function loadTurnstile(): Promise<TurnstileApi>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createScriptLoader } from './loadExternalScript.js';
|
|
2
|
+
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
|
3
|
+
const loadTurnstileScript = createScriptLoader(TURNSTILE_SRC, () => window.turnstile ?? null);
|
|
4
|
+
export function loadTurnstile() {
|
|
5
|
+
return loadTurnstileScript();
|
|
6
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CrossTabRefreshPort } from '../core/session.js';
|
|
2
|
+
/**
|
|
3
|
+
* Web Locks + BroadcastChannel implementation of the cross-tab refresh port.
|
|
4
|
+
* The coordination key names both the lock and the channel. Resolves to
|
|
5
|
+
* undefined where either API is missing (older browsers, non-browser and
|
|
6
|
+
* unit-test environments), so hosts fall back to uncoordinated per-tab
|
|
7
|
+
* refresh.
|
|
8
|
+
*
|
|
9
|
+
* Trust boundary, decided rather than assumed: the workspace JWT travels on
|
|
10
|
+
* a same-origin BroadcastChannel whose name any script in the page can
|
|
11
|
+
* derive from the uid and workspace id, and an inbound message is accepted
|
|
12
|
+
* on shape, uid, workspace and a newer expiry, all of which a script in the
|
|
13
|
+
* realm can read and forge. Same-origin script already holds the token and
|
|
14
|
+
* the session client's storage adapter, so this widens what such a script
|
|
15
|
+
* can do to siblings (hand them a token), not what it can reach. Hosts that
|
|
16
|
+
* load third-party script into the realm accept that when they opt in.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createWebCrossTabRefreshPort(): CrossTabRefreshPort | undefined;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Locks + BroadcastChannel implementation of the cross-tab refresh port.
|
|
3
|
+
* The coordination key names both the lock and the channel. Resolves to
|
|
4
|
+
* undefined where either API is missing (older browsers, non-browser and
|
|
5
|
+
* unit-test environments), so hosts fall back to uncoordinated per-tab
|
|
6
|
+
* refresh.
|
|
7
|
+
*
|
|
8
|
+
* Trust boundary, decided rather than assumed: the workspace JWT travels on
|
|
9
|
+
* a same-origin BroadcastChannel whose name any script in the page can
|
|
10
|
+
* derive from the uid and workspace id, and an inbound message is accepted
|
|
11
|
+
* on shape, uid, workspace and a newer expiry, all of which a script in the
|
|
12
|
+
* realm can read and forge. Same-origin script already holds the token and
|
|
13
|
+
* the session client's storage adapter, so this widens what such a script
|
|
14
|
+
* can do to siblings (hand them a token), not what it can reach. Hosts that
|
|
15
|
+
* load third-party script into the realm accept that when they opt in.
|
|
16
|
+
*/
|
|
17
|
+
export function createWebCrossTabRefreshPort() {
|
|
18
|
+
// Feature-detect defensively: test DOMs ship these piecemeal (happy-dom
|
|
19
|
+
// has a BroadcastChannel but a null navigator.locks), and either gap must
|
|
20
|
+
// resolve to undefined.
|
|
21
|
+
if (typeof BroadcastChannel === 'undefined')
|
|
22
|
+
return undefined;
|
|
23
|
+
if (typeof navigator === 'undefined')
|
|
24
|
+
return undefined;
|
|
25
|
+
const locks = navigator.locks;
|
|
26
|
+
if (!locks)
|
|
27
|
+
return undefined;
|
|
28
|
+
// A present BroadcastChannel can still throw on construction (policy,
|
|
29
|
+
// sandbox, privacy mode, partial impl); probe once so the port is either
|
|
30
|
+
// fully usable or absent, never interrupted mid-commit.
|
|
31
|
+
try {
|
|
32
|
+
const probe = new BroadcastChannel('@comfyorg/account-core cross-tab probe');
|
|
33
|
+
probe.close();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
// One refcounted channel per subscribed key, shared by subscribe and
|
|
39
|
+
// publish. A channel never delivers a message back to itself, so
|
|
40
|
+
// publishing on the subscription channel keeps a tab from hearing its own
|
|
41
|
+
// broadcasts.
|
|
42
|
+
const channels = new Map();
|
|
43
|
+
return {
|
|
44
|
+
requestLeadership(key, onAcquired) {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
let disposed = false;
|
|
47
|
+
let releaseHeld;
|
|
48
|
+
void locks
|
|
49
|
+
.request(key, { signal: controller.signal }, () => {
|
|
50
|
+
// A grant can win the race against our own abandon; returning
|
|
51
|
+
// without holding releases the lock straight to the next tab.
|
|
52
|
+
if (disposed)
|
|
53
|
+
return;
|
|
54
|
+
onAcquired();
|
|
55
|
+
// onAcquired may dispose synchronously, and neither abort() (a
|
|
56
|
+
// no-op on a granted lock) nor releaseHeld (not wired yet) can
|
|
57
|
+
// observe that — re-check before committing to the hold.
|
|
58
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the narrowing is not call-aware; onAcquired() can flip this
|
|
59
|
+
if (disposed)
|
|
60
|
+
return;
|
|
61
|
+
// Hold the lock until released; the browser releases it for us
|
|
62
|
+
// when the tab dies, which is what promotes the next tab.
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
releaseHeld = resolve;
|
|
65
|
+
});
|
|
66
|
+
})
|
|
67
|
+
.catch((error) => {
|
|
68
|
+
// Abandoning the request aborts it; anything else means this tab
|
|
69
|
+
// silently never leads, which deserves a trace.
|
|
70
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
console.warn('Cross-tab refresh leadership request failed:', error);
|
|
74
|
+
});
|
|
75
|
+
return () => {
|
|
76
|
+
disposed = true;
|
|
77
|
+
controller.abort();
|
|
78
|
+
releaseHeld?.();
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
publishCredential(key, credential) {
|
|
82
|
+
const entry = channels.get(key);
|
|
83
|
+
if (entry !== undefined) {
|
|
84
|
+
entry.channel.postMessage(credential);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// No subscriber on this key here: publish on an ephemeral channel so
|
|
88
|
+
// nothing is left open with no owner to close it.
|
|
89
|
+
const channel = new BroadcastChannel(key);
|
|
90
|
+
channel.postMessage(credential);
|
|
91
|
+
channel.close();
|
|
92
|
+
},
|
|
93
|
+
onCredential(key, callback) {
|
|
94
|
+
let entry = channels.get(key);
|
|
95
|
+
if (entry === undefined) {
|
|
96
|
+
entry = { channel: new BroadcastChannel(key), subscribers: 0 };
|
|
97
|
+
channels.set(key, entry);
|
|
98
|
+
}
|
|
99
|
+
entry.subscribers += 1;
|
|
100
|
+
const subscribed = entry;
|
|
101
|
+
const handler = (event) => {
|
|
102
|
+
const message = event.data;
|
|
103
|
+
callback(message);
|
|
104
|
+
};
|
|
105
|
+
subscribed.channel.addEventListener('message', handler);
|
|
106
|
+
let active = true;
|
|
107
|
+
return () => {
|
|
108
|
+
if (!active)
|
|
109
|
+
return;
|
|
110
|
+
active = false;
|
|
111
|
+
subscribed.channel.removeEventListener('message', handler);
|
|
112
|
+
subscribed.subscribers -= 1;
|
|
113
|
+
if (subscribed.subscribers === 0) {
|
|
114
|
+
subscribed.channel.close();
|
|
115
|
+
if (channels.get(key) === subscribed)
|
|
116
|
+
channels.delete(key);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const SOCIAL_APP_PATTERNS = /FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i;
|
|
2
|
+
const IOS_FIRST_PARTY_BROWSER_PATTERNS = /CriOS|FxiOS|OPiOS|EdgiOS/i;
|
|
3
|
+
function isAndroidWebView(ua) {
|
|
4
|
+
return /\bwv\b/.test(ua) && /Android/.test(ua);
|
|
5
|
+
}
|
|
6
|
+
function isIOSWebView(ua) {
|
|
7
|
+
if (!/AppleWebKit/i.test(ua))
|
|
8
|
+
return false;
|
|
9
|
+
if (/Safari\//i.test(ua))
|
|
10
|
+
return false;
|
|
11
|
+
if (IOS_FIRST_PARTY_BROWSER_PATTERNS.test(ua))
|
|
12
|
+
return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
function isSocialAppBrowser(ua) {
|
|
16
|
+
return SOCIAL_APP_PATTERNS.test(ua);
|
|
17
|
+
}
|
|
18
|
+
function isFirstPartyIOSBrowser(ua) {
|
|
19
|
+
if (IOS_FIRST_PARTY_BROWSER_PATTERNS.test(ua))
|
|
20
|
+
return true;
|
|
21
|
+
if (!/iPhone|iPad|iPod|Macintosh.*Mobile\//i.test(ua))
|
|
22
|
+
return false;
|
|
23
|
+
return /Safari\//i.test(ua) && /Version\//i.test(ua);
|
|
24
|
+
}
|
|
25
|
+
function hasWkWebViewMessageBridge() {
|
|
26
|
+
try {
|
|
27
|
+
const win = globalThis;
|
|
28
|
+
return (typeof win.webkit === 'object' &&
|
|
29
|
+
win.webkit !== null &&
|
|
30
|
+
typeof win.webkit.messageHandlers ===
|
|
31
|
+
'object');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function hasReactNativeWebViewBridge() {
|
|
38
|
+
try {
|
|
39
|
+
const win = globalThis;
|
|
40
|
+
return win.ReactNativeWebView != null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether the page runs inside an app's embedded browser, where Google's
|
|
48
|
+
* popup sign-in is blocked. Both hosts show the same notice on that answer.
|
|
49
|
+
*/
|
|
50
|
+
export function isEmbeddedWebView(ua = navigator.userAgent) {
|
|
51
|
+
if (isSocialAppBrowser(ua))
|
|
52
|
+
return true;
|
|
53
|
+
if (isAndroidWebView(ua))
|
|
54
|
+
return true;
|
|
55
|
+
if (isIOSWebView(ua))
|
|
56
|
+
return true;
|
|
57
|
+
if (hasReactNativeWebViewBridge())
|
|
58
|
+
return true;
|
|
59
|
+
if (!isFirstPartyIOSBrowser(ua) && hasWkWebViewMessageBridge())
|
|
60
|
+
return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|