@comfyorg/account-core 1.0.0-alpha.1 → 1.0.0-alpha.3

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 (76) hide show
  1. package/dist/core/billing/index.d.ts +4 -3
  2. package/dist/core/billing/index.js +2 -1
  3. package/dist/core/billing/operationLifecycle.d.ts +26 -4
  4. package/dist/core/billing/operationLifecycle.js +113 -14
  5. package/dist/core/billing/operationPointer.d.ts +24 -0
  6. package/dist/core/billing/operationPointer.js +14 -1
  7. package/dist/core/billing/operationState.d.ts +135 -1
  8. package/dist/core/billing/operationState.js +15 -2
  9. package/dist/core/billing/paymentFriction.d.ts +17 -0
  10. package/dist/core/billing/paymentFriction.js +41 -0
  11. package/dist/core/billing/subscriptionCommands.d.ts +64 -2
  12. package/dist/core/billing/subscriptionCommands.js +31 -7
  13. package/dist/core/billing/subscriptionDiscount.d.ts +26 -0
  14. package/dist/core/billing/subscriptionDiscount.js +6 -0
  15. package/dist/core/billing/telemetry/billingTelemetryEvent.d.ts +25 -0
  16. package/dist/core/billing/telemetry/billingTelemetryEvent.js +3 -0
  17. package/dist/core/billing/telemetry/cancelEvent.d.ts +19 -0
  18. package/dist/core/billing/telemetry/cancelEvent.js +1 -0
  19. package/dist/core/billing/telemetry/capabilityReadEvent.d.ts +4 -0
  20. package/dist/core/billing/telemetry/capabilityReadEvent.js +1 -0
  21. package/dist/core/billing/telemetry/checkoutChallengeEvent.d.ts +25 -0
  22. package/dist/core/billing/telemetry/checkoutChallengeEvent.js +1 -0
  23. package/dist/core/billing/telemetry/checkoutJourney.d.ts +176 -0
  24. package/dist/core/billing/telemetry/checkoutJourney.js +115 -0
  25. package/dist/core/billing/telemetry/checkoutRedirectEvent.d.ts +30 -0
  26. package/dist/core/billing/telemetry/checkoutRedirectEvent.js +1 -0
  27. package/dist/core/billing/telemetry/downgradeToPersonalEvent.d.ts +14 -0
  28. package/dist/core/billing/telemetry/downgradeToPersonalEvent.js +1 -0
  29. package/dist/core/billing/telemetry/entryEvent.d.ts +16 -0
  30. package/dist/core/billing/telemetry/entryEvent.js +1 -0
  31. package/dist/core/billing/telemetry/eventNames.d.ts +36 -0
  32. package/dist/core/billing/telemetry/eventNames.js +36 -0
  33. package/dist/core/billing/telemetry/index.d.ts +17 -0
  34. package/dist/core/billing/telemetry/index.js +6 -0
  35. package/dist/core/billing/telemetry/operationEvent.d.ts +25 -0
  36. package/dist/core/billing/telemetry/operationEvent.js +1 -0
  37. package/dist/core/billing/telemetry/operationLifecycleEvent.d.ts +9 -0
  38. package/dist/core/billing/telemetry/operationLifecycleEvent.js +79 -0
  39. package/dist/core/billing/telemetry/payload.d.ts +55 -0
  40. package/dist/core/billing/telemetry/payload.js +75 -0
  41. package/dist/core/billing/telemetry/portalEvent.d.ts +23 -0
  42. package/dist/core/billing/telemetry/portalEvent.js +1 -0
  43. package/dist/core/billing/telemetry/resubscribeEvent.d.ts +13 -0
  44. package/dist/core/billing/telemetry/resubscribeEvent.js +1 -0
  45. package/dist/core/billing/telemetry/stages.d.ts +45 -0
  46. package/dist/core/billing/telemetry/stages.js +1 -0
  47. package/dist/core/billing/telemetry/subscriptionCheckoutEvent.d.ts +23 -0
  48. package/dist/core/billing/telemetry/subscriptionCheckoutEvent.js +1 -0
  49. package/dist/core/billing/telemetry/topupEvent.d.ts +30 -0
  50. package/dist/core/billing/telemetry/topupEvent.js +6 -0
  51. package/dist/core/billing/telemetry/vocabulary.d.ts +9 -0
  52. package/dist/core/billing/telemetry/vocabulary.js +1 -0
  53. package/dist/core/billing/telemetry/webEntryEvent.d.ts +27 -0
  54. package/dist/core/billing/telemetry/webEntryEvent.js +1 -0
  55. package/dist/core/billing/telemetry/webHandoffEvent.d.ts +11 -0
  56. package/dist/core/billing/telemetry/webHandoffEvent.js +1 -0
  57. package/dist/core/billing/telemetry/webReturnEvent.d.ts +8 -0
  58. package/dist/core/billing/telemetry/webReturnEvent.js +1 -0
  59. package/dist/core/billing/telemetry/webSessionEvent.d.ts +17 -0
  60. package/dist/core/billing/telemetry/webSessionEvent.js +1 -0
  61. package/dist/core/requestTimeout.d.ts +7 -0
  62. package/dist/core/requestTimeout.js +13 -0
  63. package/dist/core/sessionTokenMint.d.ts +3 -1
  64. package/dist/core/sessionTokenMint.js +28 -11
  65. package/dist/core/webSession.d.ts +6 -3
  66. package/dist/core/webSession.js +31 -23
  67. package/dist/core/webSessionFlag.d.ts +1 -2
  68. package/dist/core/webSessionFlag.js +1 -2
  69. package/dist/core/webSessionIdentity.d.ts +12 -6
  70. package/dist/core/webSessionIdentity.js +17 -4
  71. package/dist/signInSchemas.d.ts +1 -1
  72. package/dist/telemetry.d.ts +26 -0
  73. package/dist/telemetry.js +28 -7
  74. package/dist/testing.d.ts +2 -1
  75. package/dist/testing.js +5 -4
  76. package/package.json +3 -1
@@ -0,0 +1,23 @@
1
+ import type { SubscribeResponse } from '@comfyorg/ingest-types';
2
+ import type { BillingDeclineReason } from '../operationState.js';
3
+ import type { BillingCheckoutReceived, BillingFailed, BillingIntent, BillingRecoveredSucceeded, BillingRequestSent, BillingStarted, BillingTimedOut } from './stages.js';
4
+ import type { BillingCycle, PaymentIntentSource, SubscriptionCheckoutTier, SubscriptionCheckoutType } from './vocabulary.js';
5
+ /** The two checkouts billing web serves; the cloud app's events name neither. */
6
+ export type SubscriptionCheckoutUi = 'embedded' | 'full_page';
7
+ export type SubscriptionCheckoutBillingEvent = {
8
+ operation: 'subscription_checkout';
9
+ billing_op_id?: string;
10
+ checkout_attempt_id?: string;
11
+ tier?: SubscriptionCheckoutTier;
12
+ cycle?: BillingCycle;
13
+ checkout_type?: SubscriptionCheckoutType;
14
+ checkout_ui?: SubscriptionCheckoutUi;
15
+ payment_intent_source?: PaymentIntentSource;
16
+ /**
17
+ * Client-observed end-to-end wall time from this attempt's canonical
18
+ * `started` event through to this terminal event.
19
+ */
20
+ duration_ms?: number;
21
+ } & (BillingIntent | BillingCheckoutReceived<SubscribeResponse['status']> | BillingRequestSent | BillingStarted | BillingRecoveredSucceeded | (BillingFailed & {
22
+ decline_reason?: BillingDeclineReason;
23
+ }) | BillingTimedOut);
@@ -0,0 +1,30 @@
1
+ import type { CreateTopupResponse } from '@comfyorg/ingest-types';
2
+ import type { BillingCheckoutReceived, BillingFailed, BillingIntent, BillingRequestSent, BillingStarted, BillingSucceeded } from './stages.js';
3
+ import type { PaymentIntentSource } from './vocabulary.js';
4
+ /** The fixed amounts, in USD, a top-up dialog offers as one-click choices. */
5
+ export declare const TOPUP_AMOUNT_PRESETS_USD: readonly [10, 25, 50, 100];
6
+ /** The preset the customer picked, or `custom` for any typed amount. */
7
+ export type TopupAmountPreset = `${(typeof TOPUP_AMOUNT_PRESETS_USD)[number]}` | 'custom';
8
+ export declare function getTopupAmountPreset(pickedPresetUsd: number | null): TopupAmountPreset;
9
+ type TopupStarted = BillingStarted & {
10
+ /** The amount being bought, in whole cents. */
11
+ amount_cents?: number;
12
+ amount_preset?: TopupAmountPreset;
13
+ };
14
+ export type TopupBillingEvent = {
15
+ operation: 'topup';
16
+ billing_op_id?: string;
17
+ /**
18
+ * Surface the top-up was opened from. Absent when the caller named none,
19
+ * exactly as on the subscription rail's events — absent is no claim, never
20
+ * an implied default. Named `payment_intent_source` to match its siblings
21
+ * above; the journey's own `entry_source` is a separate, smaller enum.
22
+ */
23
+ payment_intent_source?: PaymentIntentSource;
24
+ /**
25
+ * Client-observed end-to-end wall time from this attempt's canonical
26
+ * `started` event through to this terminal event.
27
+ */
28
+ duration_ms?: number;
29
+ } & (BillingIntent | BillingCheckoutReceived<CreateTopupResponse['status']> | BillingRequestSent | TopupStarted | BillingSucceeded | BillingFailed);
30
+ export {};
@@ -0,0 +1,6 @@
1
+ /** The fixed amounts, in USD, a top-up dialog offers as one-click choices. */
2
+ export const TOPUP_AMOUNT_PRESETS_USD = [10, 25, 50, 100];
3
+ export function getTopupAmountPreset(pickedPresetUsd) {
4
+ const preset = TOPUP_AMOUNT_PRESETS_USD.find((usd) => usd === pickedPresetUsd);
5
+ return preset === undefined ? 'custom' : `${preset}`;
6
+ }
@@ -0,0 +1,9 @@
1
+ export type BillingTierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder';
2
+ export type BillingCycle = 'monthly' | 'yearly';
3
+ export type SubscriptionCheckoutType = 'new' | 'change';
4
+ export type SubscriptionCheckoutTier = BillingTierKey | 'team';
5
+ export type ResubscribeSource = 'pricing_dialog' | 'settings_billing_panel' | 'billing_web_subscription';
6
+ /** Mirrors `BillingSource` in `@comfyorg/billing-contract`; a type test there fails when the two drift. */
7
+ export type PaymentIntentSource = 'subscription_required' | 'out_of_credits' | 'top_up_blocked' | 'deep_link' | 'subscribe_to_run' | 'subscribe_now_button' | 'upgrade_to_add_credits' | 'settings_billing_panel' | 'avatar_menu_plans' | 'team_members_panel' | 'invite_member_upsell' | 'upload_model_upgrade' | 'team_upgrade_resume' | 'free_tier_quota' | 'agent_paywall';
8
+ /** Mirrors `BillingIntent` in `@comfyorg/billing-contract`; a type test there fails when the two drift. */
9
+ export type HostedBillingIntent = 'pricing' | 'checkout' | 'top-up' | 'subscription' | 'payment-methods' | 'invoices' | 'result';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import type { HostedBillingIntent, PaymentIntentSource } from './vocabulary.js';
2
+ /** Mirrors `BillingProduct` in `@comfyorg/billing-contract`; a type test there fails when the two drift. */
3
+ export type WebEntryProduct = 'comfyui' | 'platform' | 'workshop' | 'models';
4
+ /** Mirrors `BillingEntryErrorCode` in `@comfyorg/billing-contract`; a type test there fails when the two drift. */
5
+ export type WebEntryErrorCode = 'UNSUPPORTED_VERSION' | 'UNKNOWN_INTENT' | 'UNKNOWN_PRODUCT' | 'UNKNOWN_RETURN_TARGET' | 'INVALID_AMOUNT' | 'INVALID_PLAN' | 'INVALID_CORRELATION_ID' | 'INVALID_WORKSPACE_ID' | 'INVALID_TEAM_CREDIT_STOP_ID' | 'INVALID_PROMOTION_CODE';
6
+ export type WebEntryBounceReason = 'pricing_link' | 'planless_checkout' | 'return_target_rewritten';
7
+ /** A `ReturnTarget` of `@comfyorg/billing-contract` (a type test there pins them) or the host's pricing table. */
8
+ export type WebEntryBounceTarget = 'comfyui_workspace' | 'comfyui_credits' | 'platform_account' | 'platform_billing' | 'pricing_table';
9
+ export type WebEntryBillingEvent = {
10
+ operation: 'web_entry';
11
+ outcome: 'pending';
12
+ } & ({
13
+ stage: 'received';
14
+ intent: HostedBillingIntent;
15
+ product: WebEntryProduct;
16
+ has_plan: boolean;
17
+ payment_intent_source?: PaymentIntentSource;
18
+ /** The cloud journey id the entry link carries; links from other products carry none. */
19
+ correlation_id?: string;
20
+ } | {
21
+ stage: 'rejected';
22
+ error_code: WebEntryErrorCode;
23
+ } | {
24
+ stage: 'bounced';
25
+ reason: WebEntryBounceReason;
26
+ to: WebEntryBounceTarget;
27
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { HostedBillingIntent, PaymentIntentSource } from './vocabulary.js';
2
+ export type WebHandoffBillingEvent = {
3
+ operation: 'web_handoff';
4
+ stage: 'opened';
5
+ outcome: 'pending';
6
+ intent: HostedBillingIntent;
7
+ result: 'opened' | 'blocked';
8
+ payment_intent_source?: PaymentIntentSource;
9
+ /** The cloud journey id the entry link carries as `correlation_id`. */
10
+ correlation_id: string;
11
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ /** Back from a checkout step, the frame's close, the finished checkout's close, or the surface's link to the host. */
2
+ export type WebReturnControl = 'back' | 'close' | 'success_close' | 'host_link';
3
+ export type WebReturnBillingEvent = {
4
+ operation: 'web_return';
5
+ stage: 'clicked';
6
+ outcome: 'pending';
7
+ control: WebReturnControl;
8
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import type { SessionErrorCode } from '../../sessionContracts.js';
2
+ export type WebSessionMode = 'session-client' | 'web-session';
3
+ export type WebSessionBillingEvent = {
4
+ operation: 'web_session';
5
+ outcome: 'pending';
6
+ } & ({
7
+ stage: 'signin_required';
8
+ reason: 'no_session' | 'refused';
9
+ } | {
10
+ stage: 'established';
11
+ /** `interactive` once the customer signed in on billing web itself; `restored` when a session was already there. */
12
+ origin: 'restored' | 'interactive';
13
+ mode: WebSessionMode;
14
+ } | {
15
+ stage: 'failed';
16
+ error_code: SessionErrorCode;
17
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface TimedSignal {
2
+ readonly signal: AbortSignal | undefined;
3
+ /** Disarms the timer; call once the response body has been read. */
4
+ readonly release: () => void;
5
+ }
6
+ /** Joins the caller's signal with an abort after `timeoutMs`, if one is set. */
7
+ export declare function timedSignal(signal: AbortSignal | undefined, timeoutMs: number | undefined): TimedSignal;
@@ -0,0 +1,13 @@
1
+ /** Joins the caller's signal with an abort after `timeoutMs`, if one is set. */
2
+ export function timedSignal(signal, timeoutMs) {
3
+ if (timeoutMs === undefined)
4
+ return { signal, release: () => undefined };
5
+ const controller = new AbortController();
6
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7
+ return {
8
+ signal: signal
9
+ ? AbortSignal.any([signal, controller.signal])
10
+ : controller.signal,
11
+ release: () => clearTimeout(timer)
12
+ };
13
+ }
@@ -8,6 +8,8 @@ export interface SessionTokenMintOptions {
8
8
  readonly now?: () => number;
9
9
  /** Re-mint once a cached token has this long left. Default 60s. */
10
10
  readonly refreshBufferMs?: number;
11
+ /** Caps each mint, body included; a timeout is `SESSION_UNAVAILABLE`. Default: none. */
12
+ readonly timeoutMs?: number;
11
13
  }
12
14
  export interface SessionTokenFailure extends WebSessionFailure {
13
15
  /** From a 429's `Retry-After`; no request is sent until it passes. */
@@ -30,4 +32,4 @@ export interface SessionTokenMint {
30
32
  readonly getWorkspaceToken: (workspaceId?: string) => Promise<string>;
31
33
  }
32
34
  export declare const DEFAULT_REFRESH_BUFFER_MS = 60000;
33
- export declare function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now, refreshBufferMs }: SessionTokenMintOptions): SessionTokenMint;
35
+ export declare function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now, refreshBufferMs, timeoutMs }: SessionTokenMintOptions): SessionTokenMint;
@@ -7,6 +7,7 @@
7
7
  import { zErrorResponse, zExchangeTokenResponse } from '@comfyorg/ingest-types/zod';
8
8
  import { isCredentialFresh } from './credentialCache.js';
9
9
  import { COMFY_CLIENT } from './requestAuth.js';
10
+ import { timedSignal } from './requestTimeout.js';
10
11
  export class SessionTokenError extends Error {
11
12
  failure;
12
13
  constructor(failure) {
@@ -21,7 +22,8 @@ const STATUS_RULES = {
21
22
  401: {
22
23
  byServerCode: {
23
24
  session_expired: 'SESSION_EXPIRED',
24
- session_revoked: 'SESSION_REVOKED'
25
+ session_revoked: 'SESSION_REVOKED',
26
+ TOKEN_REVOKED: 'SESSION_REVOKED'
25
27
  },
26
28
  fallback: 'NO_SESSION'
27
29
  },
@@ -31,6 +33,11 @@ const STATUS_RULES = {
31
33
  workspace_access_denied: 'WORKSPACE_ACCESS_DENIED'
32
34
  },
33
35
  fallback: 'SESSION_REQUEST_REFUSED'
36
+ },
37
+ /** The workspace is unknown, deleted, or not the user's: one remedy. */
38
+ 404: {
39
+ byServerCode: { NOT_FOUND: 'WORKSPACE_ACCESS_DENIED' },
40
+ fallback: 'SESSION_REQUEST_REFUSED'
34
41
  }
35
42
  };
36
43
  const zServerCode = zErrorResponse.pick({ code: true });
@@ -72,17 +79,17 @@ function codeFor(status, serverCode) {
72
79
  return 'SESSION_REQUEST_REFUSED';
73
80
  return rule.byServerCode[serverCode ?? ''] ?? rule.fallback;
74
81
  }
75
- async function classifyFailure(response, nowMs) {
82
+ function classifyFailure(response, body, nowMs) {
76
83
  const { status } = response;
77
84
  if (status === 429)
78
85
  return rateLimited(response, nowMs);
79
86
  if (status >= 500)
80
87
  return failure('SESSION_UNAVAILABLE', status);
81
- const parsed = zServerCode.safeParse(await readJson(response));
88
+ const parsed = zServerCode.safeParse(body);
82
89
  const serverCode = parsed.success ? parsed.data.code : undefined;
83
90
  return failure(codeFor(status, serverCode), status, serverCode);
84
91
  }
85
- export function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now = Date.now, refreshBufferMs = DEFAULT_REFRESH_BUFFER_MS }) {
92
+ export function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now = Date.now, refreshBufferMs = DEFAULT_REFRESH_BUFFER_MS, timeoutMs }) {
86
93
  const tokenUrl = `${apiBaseUrl.replace(/\/+$/, '')}/auth/token`;
87
94
  /** Keyed by workspace; every entry belongs to `cacheOwner`. */
88
95
  const cache = new Map();
@@ -101,9 +108,9 @@ export function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now
101
108
  rateLimit = undefined;
102
109
  }
103
110
  async function request(session, workspaceId) {
104
- let response;
111
+ const { signal, release } = timedSignal(undefined, timeoutMs);
105
112
  try {
106
- response = await fetchImpl(tokenUrl, {
113
+ const response = await fetchImpl(tokenUrl, {
107
114
  method: 'POST',
108
115
  credentials: 'include',
109
116
  headers: {
@@ -114,20 +121,30 @@ export function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now
114
121
  ? {}
115
122
  : { 'X-Comfy-Workspace-ID': workspaceId })
116
123
  },
117
- body: JSON.stringify(workspaceId === undefined ? {} : { workspace_id: workspaceId })
124
+ body: JSON.stringify(workspaceId === undefined ? {} : { workspace_id: workspaceId }),
125
+ signal
118
126
  });
127
+ const body = await readJson(response);
128
+ if (signal?.aborted)
129
+ return failure('SESSION_UNAVAILABLE');
130
+ return response.ok
131
+ ? credentialFrom(session, response.status, body)
132
+ : classifyFailure(response, body, now());
119
133
  }
120
134
  catch {
121
135
  return failure('SESSION_UNAVAILABLE');
122
136
  }
123
- if (!response.ok)
124
- return classifyFailure(response, now());
125
- const parsed = zExchangeTokenResponse.safeParse(await readJson(response));
137
+ finally {
138
+ release();
139
+ }
140
+ }
141
+ function credentialFrom(session, status, body) {
142
+ const parsed = zExchangeTokenResponse.safeParse(body);
126
143
  const expiresAt = parsed.success
127
144
  ? Date.parse(parsed.data.expires_at)
128
145
  : Number.NaN;
129
146
  if (!parsed.success || parsed.data.token === '' || !(expiresAt > now())) {
130
- return failure('SESSION_UNAVAILABLE', response.status);
147
+ return failure('SESSION_UNAVAILABLE', status);
131
148
  }
132
149
  return {
133
150
  status: 'ok',
@@ -5,6 +5,8 @@ export interface WebSessionOptions {
5
5
  readonly apiBaseUrl: string;
6
6
  readonly fetchImpl: typeof fetch;
7
7
  readonly signal?: AbortSignal;
8
+ /** Caps each request, body included; a timeout is `SESSION_UNAVAILABLE`. Default: none. */
9
+ readonly timeoutMs?: number;
8
10
  }
9
11
  export declare function readWebSession(options: WebSessionOptions, { expectedUserId }?: {
10
12
  readonly expectedUserId?: string;
@@ -19,7 +21,8 @@ export declare function createWebSession(options: WebSessionOptions, getIdentity
19
21
  /** Sends no CSRF token: sign-out must work against a dead session. */
20
22
  export declare function deleteWebSession(options: WebSessionOptions): Promise<WebSessionCommandResult>;
21
23
  /**
22
- * Signs the user out of every device. Only a body the contract recognises is
23
- * ok: the caller tells the user every device is signed out on that answer.
24
+ * Signs the user out of every device with the session cookie alone. Only a
25
+ * body the contract recognises is ok: the caller tells the user every device
26
+ * is signed out on that answer.
24
27
  */
25
- export declare function revokeAllWebSessions(options: WebSessionOptions, csrfToken: string, getIdentityProof: () => Promise<string>): Promise<WebSessionCommandResult>;
28
+ export declare function revokeAllWebSessions(options: WebSessionOptions, csrfToken: string): Promise<WebSessionCommandResult>;
@@ -4,6 +4,8 @@
4
4
  * Identity proof is an opaque string from the caller, so no provider leaks in.
5
5
  */
6
6
  import { zCreateSessionResponse, zDeleteSessionResponse, zErrorResponse, zGetSessionResponse, zRevokeAllSessionsResponse } from '@comfyorg/ingest-types/zod';
7
+ import { COMFY_CLIENT } from './requestAuth.js';
8
+ import { timedSignal } from './requestTimeout.js';
7
9
  const UNAUTHORIZED_CODES = {
8
10
  no_session: 'NO_SESSION',
9
11
  session_expired: 'SESSION_EXPIRED',
@@ -31,12 +33,11 @@ async function readJson(response) {
31
33
  return undefined;
32
34
  }
33
35
  }
34
- async function classifyFailure(response) {
35
- const { status } = response;
36
+ function classifyFailure(status, body) {
36
37
  if (status === 429 || status >= 500) {
37
38
  return failure('SESSION_UNAVAILABLE', status);
38
39
  }
39
- const parsed = zErrorResponse.safeParse(await readJson(response));
40
+ const parsed = zErrorResponse.safeParse(body);
40
41
  if (!parsed.success)
41
42
  return failure('SESSION_REQUEST_REFUSED', status);
42
43
  const serverCode = parsed.data.code;
@@ -48,24 +49,31 @@ async function classifyFailure(response) {
48
49
  return failure(byServerCode, status, serverCode);
49
50
  }
50
51
  async function send(options, path, init) {
51
- let response;
52
+ const { signal, release } = timedSignal(options.signal, options.timeoutMs);
52
53
  try {
53
- response = await options.fetchImpl(`${options.apiBaseUrl.replace(/\/+$/, '')}${path}`, { ...init, credentials: 'include', signal: options.signal });
54
+ const response = await options.fetchImpl(`${options.apiBaseUrl.replace(/\/+$/, '')}${path}`, { ...init, credentials: 'include', signal });
55
+ const body = await readJson(response);
56
+ if (signal?.aborted)
57
+ return failure('SESSION_UNAVAILABLE');
58
+ const { status } = response;
59
+ return response.ok ? { status, body } : classifyFailure(status, body);
54
60
  }
55
61
  catch {
56
62
  return failure('SESSION_UNAVAILABLE');
57
63
  }
58
- return response.ok ? { response } : classifyFailure(response);
64
+ finally {
65
+ release();
66
+ }
59
67
  }
60
68
  export async function readWebSession(options, { expectedUserId } = {}) {
61
69
  const sent = await send(options, '/auth/session', {
62
70
  method: 'GET',
63
71
  cache: 'no-store'
64
72
  });
65
- if (!('response' in sent))
73
+ if ('code' in sent)
66
74
  return sent;
67
- const { status } = sent.response;
68
- const parsed = zGetSessionResponse.safeParse(await readJson(sent.response));
75
+ const { status } = sent;
76
+ const parsed = zGetSessionResponse.safeParse(sent.body);
69
77
  if (!parsed.success)
70
78
  return failure('SESSION_UNAVAILABLE', status);
71
79
  const { user, csrf_token, expires_at, absolute_expires_at } = parsed.data;
@@ -98,40 +106,40 @@ export async function createWebSession(options, getIdentityProof, { expectedUser
98
106
  method: 'POST',
99
107
  headers: { Authorization: `Bearer ${proof}` }
100
108
  });
101
- if (!('response' in sent))
109
+ if ('code' in sent)
102
110
  return sent;
103
- const parsed = zCreateSessionResponse.safeParse(await readJson(sent.response));
111
+ const parsed = zCreateSessionResponse.safeParse(sent.body);
104
112
  if (!parsed.success || !parsed.data.success) {
105
- return failure('SESSION_UNAVAILABLE', sent.response.status);
113
+ return failure('SESSION_UNAVAILABLE', sent.status);
106
114
  }
107
115
  return readWebSession(options, { expectedUserId });
108
116
  }
109
117
  /** Sends no CSRF token: sign-out must work against a dead session. */
110
118
  export async function deleteWebSession(options) {
111
119
  const sent = await send(options, '/auth/session', { method: 'DELETE' });
112
- if (!('response' in sent))
120
+ if ('code' in sent)
113
121
  return sent;
114
- const parsed = zDeleteSessionResponse.safeParse(await readJson(sent.response));
122
+ const parsed = zDeleteSessionResponse.safeParse(sent.body);
115
123
  if (!parsed.success || !parsed.data.success) {
116
- return failure('SESSION_UNAVAILABLE', sent.response.status);
124
+ return failure('SESSION_UNAVAILABLE', sent.status);
117
125
  }
118
126
  return { status: 'ok' };
119
127
  }
120
128
  /**
121
- * Signs the user out of every device. Only a body the contract recognises is
122
- * ok: the caller tells the user every device is signed out on that answer.
129
+ * Signs the user out of every device with the session cookie alone. Only a
130
+ * body the contract recognises is ok: the caller tells the user every device
131
+ * is signed out on that answer.
123
132
  */
124
- export async function revokeAllWebSessions(options, csrfToken, getIdentityProof) {
125
- const proof = await getIdentityProof();
133
+ export async function revokeAllWebSessions(options, csrfToken) {
126
134
  const sent = await send(options, '/auth/sessions/revoke-all', {
127
135
  method: 'POST',
128
- headers: { Authorization: `Bearer ${proof}`, 'X-CSRF-Token': csrfToken }
136
+ headers: { 'X-Comfy-Client': COMFY_CLIENT, 'X-CSRF-Token': csrfToken }
129
137
  });
130
- if (!('response' in sent))
138
+ if ('code' in sent)
131
139
  return sent;
132
- const parsed = zRevokeAllSessionsResponse.safeParse(await readJson(sent.response));
140
+ const parsed = zRevokeAllSessionsResponse.safeParse(sent.body);
133
141
  if (!parsed.success) {
134
- return failure('SESSION_UNAVAILABLE', sent.response.status);
142
+ return failure('SESSION_UNAVAILABLE', sent.status);
135
143
  }
136
144
  return { status: 'ok' };
137
145
  }
@@ -2,8 +2,7 @@
2
2
  * `unified_web_session` for a site with no sign-in of its own to ask with.
3
3
  * The anonymous `/api/features` document's global `web_session_probe` only
4
4
  * decides whether to ask; the answer is the credentialed read's
5
- * `unified_web_session` (cloud#10689). `web_session_probe` is a backend
6
- * follow-up to cloud#10689 and not served yet. Every failure is `false`.
5
+ * `unified_web_session` (cloud#10689). Every failure is `false`.
7
6
  */
8
7
  export interface FeaturesReadOptions {
9
8
  /** Cloud origin, e.g. `https://cloud.comfy.org`. */
@@ -2,8 +2,7 @@
2
2
  * `unified_web_session` for a site with no sign-in of its own to ask with.
3
3
  * The anonymous `/api/features` document's global `web_session_probe` only
4
4
  * decides whether to ask; the answer is the credentialed read's
5
- * `unified_web_session` (cloud#10689). `web_session_probe` is a backend
6
- * follow-up to cloud#10689 and not served yet. Every failure is `false`.
5
+ * `unified_web_session` (cloud#10689). Every failure is `false`.
7
6
  */
8
7
  const DEFAULT_TIMEOUT_MS = 5000;
9
8
  function readsLiteralTrue(body, key) {
@@ -136,6 +136,12 @@ export interface WebSessionIdentity {
136
136
  /** Only after an interactive sign-in; a token refresh must never call it. */
137
137
  signedIn: (getProof: () => Promise<string>) => Promise<WebSessionResult>;
138
138
  signOut: () => Promise<WebSessionCommandResult>;
139
+ /**
140
+ * Reads the session now, as a heartbeat would, and resolves once the answer
141
+ * and any restore it starts have settled. Reads nothing unless signed in as
142
+ * `expectedUserId` (any user when omitted).
143
+ */
144
+ refresh: (expectedUserId?: string) => Promise<WebSessionIdentityState>;
139
145
  /** Changes with the account; an answer started under an older epoch is stale. */
140
146
  getEpoch: () => number;
141
147
  /** Cancels timers, ignores answers in flight, and returns to idle. */
@@ -181,8 +187,8 @@ declare const zSharedMessage: z.ZodObject<{
181
187
  name?: string | undefined;
182
188
  signInProvider?: string | undefined;
183
189
  };
184
- expiresAt: number;
185
190
  csrfToken: string;
191
+ expiresAt: number;
186
192
  absoluteExpiresAt: number;
187
193
  }, {
188
194
  user: {
@@ -192,8 +198,8 @@ declare const zSharedMessage: z.ZodObject<{
192
198
  name?: string | undefined;
193
199
  signInProvider?: string | undefined;
194
200
  };
195
- expiresAt: number;
196
201
  csrfToken: string;
202
+ expiresAt: number;
197
203
  absoluteExpiresAt: number;
198
204
  }>;
199
205
  }, "strip", z.ZodTypeAny, {
@@ -206,8 +212,8 @@ declare const zSharedMessage: z.ZodObject<{
206
212
  name?: string | undefined;
207
213
  signInProvider?: string | undefined;
208
214
  };
209
- expiresAt: number;
210
215
  csrfToken: string;
216
+ expiresAt: number;
211
217
  absoluteExpiresAt: number;
212
218
  };
213
219
  }, {
@@ -220,8 +226,8 @@ declare const zSharedMessage: z.ZodObject<{
220
226
  name?: string | undefined;
221
227
  signInProvider?: string | undefined;
222
228
  };
223
- expiresAt: number;
224
229
  csrfToken: string;
230
+ expiresAt: number;
225
231
  absoluteExpiresAt: number;
226
232
  };
227
233
  }>, z.ZodObject<{
@@ -248,8 +254,8 @@ declare const zSharedMessage: z.ZodObject<{
248
254
  name?: string | undefined;
249
255
  signInProvider?: string | undefined;
250
256
  };
251
- expiresAt: number;
252
257
  csrfToken: string;
258
+ expiresAt: number;
253
259
  absoluteExpiresAt: number;
254
260
  };
255
261
  } | {
@@ -269,8 +275,8 @@ declare const zSharedMessage: z.ZodObject<{
269
275
  name?: string | undefined;
270
276
  signInProvider?: string | undefined;
271
277
  };
272
- expiresAt: number;
273
278
  csrfToken: string;
279
+ expiresAt: number;
274
280
  absoluteExpiresAt: number;
275
281
  };
276
282
  } | {
@@ -261,6 +261,7 @@ function createApiKeyIdentity() {
261
261
  retryable: false
262
262
  }),
263
263
  signOut: async () => ({ status: 'ok' }),
264
+ refresh: async () => state,
264
265
  getEpoch: () => 0,
265
266
  dispose: () => undefined
266
267
  };
@@ -280,6 +281,7 @@ function createAccountIdentity(options, login) {
280
281
  let cancelBeat;
281
282
  let releaseLeadership;
282
283
  let stopWatching = [];
284
+ let restoring = { epoch, settled: Promise.resolve() };
283
285
  function dispatch(event) {
284
286
  const next = transitionWebSessionIdentity(state, event);
285
287
  const changed = next.state !== state;
@@ -311,7 +313,7 @@ function createAccountIdentity(options, login) {
311
313
  function beat() {
312
314
  const started = epoch;
313
315
  const observed = { type: 'session_observed', from: 'this_tab' };
314
- void answer(started, observed, async () => {
316
+ return answer(started, observed, async () => {
315
317
  const result = await readWebSession(options.session);
316
318
  const shared = result.status === 'ok' || result.code === 'SESSION_REVOKED';
317
319
  if (shared && started === epoch)
@@ -343,7 +345,7 @@ function createAccountIdentity(options, login) {
343
345
  }
344
346
  function armBeat() {
345
347
  cancelBeat = schedule(() => {
346
- beat();
348
+ void beat();
347
349
  armBeat();
348
350
  }, heartbeat?.intervalMs ?? HEARTBEAT_INTERVAL_MS);
349
351
  }
@@ -377,7 +379,7 @@ function createAccountIdentity(options, login) {
377
379
  stopWatching = [
378
380
  visibility.onChange((visible) => {
379
381
  if (visible && state.phase === 'signed_in')
380
- beat();
382
+ void beat();
381
383
  syncLeadership();
382
384
  }),
383
385
  ...(crossTab ? [crossTab.onCredential(HEARTBEAT_KEY, adopt)] : [])
@@ -400,7 +402,7 @@ function createAccountIdentity(options, login) {
400
402
  void answer(epoch, { type: 'read_answered' }, () => readWebSession(options.session));
401
403
  return;
402
404
  case 'restore':
403
- void restore(epoch, effect.expectedUserId);
405
+ restoring = { epoch, settled: restore(epoch, effect.expectedUserId) };
404
406
  return;
405
407
  case 'schedule_retry':
406
408
  cancelRetry = schedule(() => {
@@ -477,6 +479,17 @@ function createAccountIdentity(options, login) {
477
479
  publish('sign_out', revoked);
478
480
  return result;
479
481
  },
482
+ refresh: async (expectedUserId) => {
483
+ if (state.phase !== 'signed_in')
484
+ return state;
485
+ if ((expectedUserId ?? state.session.user.id) !== state.session.user.id) {
486
+ return state;
487
+ }
488
+ await beat();
489
+ if (restoring.epoch === epoch)
490
+ await restoring.settled;
491
+ return state;
492
+ },
480
493
  getEpoch: () => epoch,
481
494
  dispose: () => {
482
495
  epoch += 1;
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  /**
3
3
  * The exact message keys the schemas emit, so a host wires every one of them
4
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.
5
+ * the app in `src/locales/en/main.json`, the website in `apps/website/src/locales/en/main.json`.
6
6
  */
7
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
8
  export type AuthSchemaTranslate = (key: AuthSchemaMessageKey, params?: Record<string, string | number>) => string;