@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.
- package/dist/core/billing/index.d.ts +4 -3
- package/dist/core/billing/index.js +2 -1
- package/dist/core/billing/operationLifecycle.d.ts +26 -4
- package/dist/core/billing/operationLifecycle.js +113 -14
- package/dist/core/billing/operationPointer.d.ts +24 -0
- package/dist/core/billing/operationPointer.js +14 -1
- package/dist/core/billing/operationState.d.ts +135 -1
- package/dist/core/billing/operationState.js +15 -2
- package/dist/core/billing/paymentFriction.d.ts +17 -0
- package/dist/core/billing/paymentFriction.js +41 -0
- package/dist/core/billing/subscriptionCommands.d.ts +64 -2
- package/dist/core/billing/subscriptionCommands.js +31 -7
- package/dist/core/billing/subscriptionDiscount.d.ts +26 -0
- package/dist/core/billing/subscriptionDiscount.js +6 -0
- package/dist/core/billing/telemetry/billingTelemetryEvent.d.ts +25 -0
- package/dist/core/billing/telemetry/billingTelemetryEvent.js +3 -0
- package/dist/core/billing/telemetry/cancelEvent.d.ts +19 -0
- package/dist/core/billing/telemetry/cancelEvent.js +1 -0
- package/dist/core/billing/telemetry/capabilityReadEvent.d.ts +4 -0
- package/dist/core/billing/telemetry/capabilityReadEvent.js +1 -0
- package/dist/core/billing/telemetry/checkoutChallengeEvent.d.ts +25 -0
- package/dist/core/billing/telemetry/checkoutChallengeEvent.js +1 -0
- package/dist/core/billing/telemetry/checkoutJourney.d.ts +176 -0
- package/dist/core/billing/telemetry/checkoutJourney.js +115 -0
- package/dist/core/billing/telemetry/checkoutRedirectEvent.d.ts +30 -0
- package/dist/core/billing/telemetry/checkoutRedirectEvent.js +1 -0
- package/dist/core/billing/telemetry/downgradeToPersonalEvent.d.ts +14 -0
- package/dist/core/billing/telemetry/downgradeToPersonalEvent.js +1 -0
- package/dist/core/billing/telemetry/entryEvent.d.ts +16 -0
- package/dist/core/billing/telemetry/entryEvent.js +1 -0
- package/dist/core/billing/telemetry/eventNames.d.ts +36 -0
- package/dist/core/billing/telemetry/eventNames.js +36 -0
- package/dist/core/billing/telemetry/index.d.ts +17 -0
- package/dist/core/billing/telemetry/index.js +6 -0
- package/dist/core/billing/telemetry/operationEvent.d.ts +25 -0
- package/dist/core/billing/telemetry/operationEvent.js +1 -0
- package/dist/core/billing/telemetry/operationLifecycleEvent.d.ts +9 -0
- package/dist/core/billing/telemetry/operationLifecycleEvent.js +79 -0
- package/dist/core/billing/telemetry/payload.d.ts +55 -0
- package/dist/core/billing/telemetry/payload.js +75 -0
- package/dist/core/billing/telemetry/portalEvent.d.ts +23 -0
- package/dist/core/billing/telemetry/portalEvent.js +1 -0
- package/dist/core/billing/telemetry/resubscribeEvent.d.ts +13 -0
- package/dist/core/billing/telemetry/resubscribeEvent.js +1 -0
- package/dist/core/billing/telemetry/stages.d.ts +45 -0
- package/dist/core/billing/telemetry/stages.js +1 -0
- package/dist/core/billing/telemetry/subscriptionCheckoutEvent.d.ts +23 -0
- package/dist/core/billing/telemetry/subscriptionCheckoutEvent.js +1 -0
- package/dist/core/billing/telemetry/topupEvent.d.ts +30 -0
- package/dist/core/billing/telemetry/topupEvent.js +6 -0
- package/dist/core/billing/telemetry/vocabulary.d.ts +9 -0
- package/dist/core/billing/telemetry/vocabulary.js +1 -0
- package/dist/core/billing/telemetry/webEntryEvent.d.ts +27 -0
- package/dist/core/billing/telemetry/webEntryEvent.js +1 -0
- package/dist/core/billing/telemetry/webHandoffEvent.d.ts +11 -0
- package/dist/core/billing/telemetry/webHandoffEvent.js +1 -0
- package/dist/core/billing/telemetry/webReturnEvent.d.ts +8 -0
- package/dist/core/billing/telemetry/webReturnEvent.js +1 -0
- package/dist/core/billing/telemetry/webSessionEvent.d.ts +17 -0
- package/dist/core/billing/telemetry/webSessionEvent.js +1 -0
- package/dist/core/requestTimeout.d.ts +7 -0
- package/dist/core/requestTimeout.js +13 -0
- package/dist/core/sessionTokenMint.d.ts +3 -1
- package/dist/core/sessionTokenMint.js +28 -11
- package/dist/core/webSession.d.ts +6 -3
- package/dist/core/webSession.js +31 -23
- package/dist/core/webSessionFlag.d.ts +1 -2
- package/dist/core/webSessionFlag.js +1 -2
- package/dist/core/webSessionIdentity.d.ts +12 -6
- package/dist/core/webSessionIdentity.js +17 -4
- package/dist/signInSchemas.d.ts +1 -1
- package/dist/telemetry.d.ts +26 -0
- package/dist/telemetry.js +28 -7
- package/dist/testing.d.ts +2 -1
- package/dist/testing.js +5 -4
- 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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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',
|
|
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
|
|
23
|
-
* ok: the caller tells the user every device
|
|
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
|
|
28
|
+
export declare function revokeAllWebSessions(options: WebSessionOptions, csrfToken: string): Promise<WebSessionCommandResult>;
|
package/dist/core/webSession.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
73
|
+
if ('code' in sent)
|
|
66
74
|
return sent;
|
|
67
|
-
const { status } = sent
|
|
68
|
-
const parsed = zGetSessionResponse.safeParse(
|
|
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 (
|
|
109
|
+
if ('code' in sent)
|
|
102
110
|
return sent;
|
|
103
|
-
const parsed = zCreateSessionResponse.safeParse(
|
|
111
|
+
const parsed = zCreateSessionResponse.safeParse(sent.body);
|
|
104
112
|
if (!parsed.success || !parsed.data.success) {
|
|
105
|
-
return failure('SESSION_UNAVAILABLE', sent.
|
|
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 (
|
|
120
|
+
if ('code' in sent)
|
|
113
121
|
return sent;
|
|
114
|
-
const parsed = zDeleteSessionResponse.safeParse(
|
|
122
|
+
const parsed = zDeleteSessionResponse.safeParse(sent.body);
|
|
115
123
|
if (!parsed.success || !parsed.data.success) {
|
|
116
|
-
return failure('SESSION_UNAVAILABLE', sent.
|
|
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
|
|
122
|
-
* ok: the caller tells the user every device
|
|
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
|
|
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: {
|
|
136
|
+
headers: { 'X-Comfy-Client': COMFY_CLIENT, 'X-CSRF-Token': csrfToken }
|
|
129
137
|
});
|
|
130
|
-
if (
|
|
138
|
+
if ('code' in sent)
|
|
131
139
|
return sent;
|
|
132
|
-
const parsed = zRevokeAllSessionsResponse.safeParse(
|
|
140
|
+
const parsed = zRevokeAllSessionsResponse.safeParse(sent.body);
|
|
133
141
|
if (!parsed.success) {
|
|
134
|
-
return failure('SESSION_UNAVAILABLE', sent.
|
|
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).
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/signInSchemas.d.ts
CHANGED
|
@@ -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
|
|
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;
|