@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,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;
@@ -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;
@@ -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,5 @@
1
+ /**
2
+ * Whether the page runs inside an app's embedded browser, where Google's
3
+ * popup sign-in is blocked. Both hosts show the same notice on that answer.
4
+ */
5
+ export declare function isEmbeddedWebView(ua?: string): boolean;
@@ -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
+ }