@comfyorg/account-core 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/core/billing/balanceWatch.d.ts +35 -0
- package/dist/core/billing/balanceWatch.js +83 -0
- package/dist/core/billing/billingContracts.d.ts +123 -0
- package/dist/core/billing/billingContracts.js +11 -0
- package/dist/core/billing/billingErrorBody.d.ts +8 -0
- package/dist/core/billing/billingErrorBody.js +11 -0
- package/dist/core/billing/billingScope.d.ts +39 -0
- package/dist/core/billing/billingScope.js +45 -0
- package/dist/core/billing/capabilities.d.ts +70 -0
- package/dist/core/billing/capabilities.js +137 -0
- package/dist/core/billing/capabilityDenials.d.ts +26 -0
- package/dist/core/billing/capabilityDenials.js +43 -0
- package/dist/core/billing/challengeDriver.d.ts +22 -0
- package/dist/core/billing/challengeDriver.js +38 -0
- package/dist/core/billing/credentialedTransport.d.ts +35 -0
- package/dist/core/billing/credentialedTransport.js +53 -0
- package/dist/core/billing/credits.d.ts +43 -0
- package/dist/core/billing/credits.js +29 -0
- package/dist/core/billing/httpStatus.d.ts +7 -0
- package/dist/core/billing/httpStatus.js +25 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.js +23 -0
- package/dist/core/billing/operationLifecycle.d.ts +77 -0
- package/dist/core/billing/operationLifecycle.js +547 -0
- package/dist/core/billing/operationPointer.d.ts +48 -0
- package/dist/core/billing/operationPointer.js +89 -0
- package/dist/core/billing/operationPolicy.d.ts +26 -0
- package/dist/core/billing/operationPolicy.js +51 -0
- package/dist/core/billing/operationState.d.ts +128 -0
- package/dist/core/billing/operationState.js +187 -0
- package/dist/core/billing/paymentCopy.d.ts +18 -0
- package/dist/core/billing/paymentCopy.js +47 -0
- package/dist/core/billing/paymentMethods.d.ts +40 -0
- package/dist/core/billing/paymentMethods.js +37 -0
- package/dist/core/billing/paymentProjection.d.ts +34 -0
- package/dist/core/billing/paymentProjection.js +56 -0
- package/dist/core/billing/plans.d.ts +44 -0
- package/dist/core/billing/plans.js +29 -0
- package/dist/core/billing/presentation.d.ts +20 -0
- package/dist/core/billing/presentation.js +10 -0
- package/dist/core/billing/scopedReader.d.ts +61 -0
- package/dist/core/billing/scopedReader.js +108 -0
- package/dist/core/billing/sharedRead.d.ts +33 -0
- package/dist/core/billing/sharedRead.js +59 -0
- package/dist/core/billing/status.d.ts +27 -0
- package/dist/core/billing/status.js +21 -0
- package/dist/core/billing/subscriptionCommands.d.ts +367 -0
- package/dist/core/billing/subscriptionCommands.js +263 -0
- package/dist/core/billing/topup.d.ts +113 -0
- package/dist/core/billing/topup.js +209 -0
- package/dist/core/billing/transport.d.ts +14 -0
- package/dist/core/billing/transport.js +106 -0
- package/dist/core/billing/transportExchange.d.ts +24 -0
- package/dist/core/billing/transportExchange.js +79 -0
- package/dist/core/boundedOperation.d.ts +16 -0
- package/dist/core/boundedOperation.js +12 -0
- package/dist/core/credentialCache.d.ts +41 -0
- package/dist/core/credentialCache.js +97 -0
- package/dist/core/customerRecovery.d.ts +39 -0
- package/dist/core/customerRecovery.js +79 -0
- package/dist/core/exchange.d.ts +50 -0
- package/dist/core/exchange.js +137 -0
- package/dist/core/identity.d.ts +21 -0
- package/dist/core/identity.js +16 -0
- package/dist/core/mintCoordinator.d.ts +18 -0
- package/dist/core/mintCoordinator.js +31 -0
- package/dist/core/refreshScheduler.d.ts +46 -0
- package/dist/core/refreshScheduler.js +262 -0
- package/dist/core/session.d.ts +146 -0
- package/dist/core/session.js +312 -0
- package/dist/core/sessionContracts.d.ts +135 -0
- package/dist/core/sessionContracts.js +9 -0
- package/dist/core/sessionState.d.ts +118 -0
- package/dist/core/sessionState.js +235 -0
- package/dist/firebase/index.d.ts +53 -0
- package/dist/firebase/index.js +74 -0
- package/dist/firebaseAuthError.d.ts +52 -0
- package/dist/firebaseAuthError.js +88 -0
- package/dist/loadExternalScript.d.ts +7 -0
- package/dist/loadExternalScript.js +88 -0
- package/dist/provisioning.d.ts +53 -0
- package/dist/provisioning.js +76 -0
- package/dist/redirect.d.ts +13 -0
- package/dist/redirect.js +38 -0
- package/dist/signInSchemas.d.ts +88 -0
- package/dist/signInSchemas.js +71 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +26 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -0
- package/dist/turnstile.d.ts +14 -0
- package/dist/turnstile.js +17 -0
- package/dist/turnstileScript.d.ts +18 -0
- package/dist/turnstileScript.js +6 -0
- package/dist/web/crossTabRefresh.d.ts +18 -0
- package/dist/web/crossTabRefresh.js +121 -0
- package/dist/webviewDetection.d.ts +5 -0
- package/dist/webviewDetection.js +62 -0
- package/package.json +114 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Comfy Org
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fallback for a top-up whose `billing_op_id` this tab never observed:
|
|
3
|
+
* with nothing to poll, the balance itself is the only evidence that the
|
|
4
|
+
* purchase landed. Ported from the cloud app's `topupBalanceRefresh` minus
|
|
5
|
+
* its document listeners — a host calls `wake` from its own focus and
|
|
6
|
+
* visibility signals, the same way it wakes the lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* A watch has no operation id at all; the SDK never fabricates one to poll
|
|
9
|
+
* instead. The top-up command reads the balance right before its POST, so
|
|
10
|
+
* `credits.getSnapshot()` after a malformed response is the baseline to arm
|
|
11
|
+
* this with.
|
|
12
|
+
*/
|
|
13
|
+
import type { CreditsReader } from './credits.js';
|
|
14
|
+
/** Gaps between reads in one run: the webhook usually lands a beat after the return. */
|
|
15
|
+
export declare const BALANCE_WATCH_RETRY_GAPS_MS: readonly [0, 2000, 5000, 10000, 20000];
|
|
16
|
+
/** A host that never comes back must not keep a watch alive forever. */
|
|
17
|
+
export declare const BALANCE_WATCH_LIFETIME_MS: number;
|
|
18
|
+
/** Full retry schedules a returning host can trigger; later wakes read once. */
|
|
19
|
+
export declare const BALANCE_WATCH_MAX_SCHEDULED_RUNS = 5;
|
|
20
|
+
export type BalanceWatchOutcome = 'reconciled' | 'expired' | 'stopped';
|
|
21
|
+
export interface BalanceWatchOptions {
|
|
22
|
+
readonly credits: Pick<CreditsReader, 'read'>;
|
|
23
|
+
/**
|
|
24
|
+
* The pre-purchase balance in micros. Absent, it is learned from the first
|
|
25
|
+
* successful read, which therefore can never count as the increase.
|
|
26
|
+
*/
|
|
27
|
+
readonly baselineMicros?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface BalanceWatch {
|
|
30
|
+
/** The host regained focus or visibility: run the read schedule. */
|
|
31
|
+
wake: () => void;
|
|
32
|
+
stop: () => void;
|
|
33
|
+
readonly outcome: Promise<BalanceWatchOutcome>;
|
|
34
|
+
}
|
|
35
|
+
export declare function createBalanceWatch(options: BalanceWatchOptions): BalanceWatch;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Gaps between reads in one run: the webhook usually lands a beat after the return. */
|
|
2
|
+
export const BALANCE_WATCH_RETRY_GAPS_MS = [
|
|
3
|
+
0, 2_000, 5_000, 10_000, 20_000
|
|
4
|
+
];
|
|
5
|
+
/** A host that never comes back must not keep a watch alive forever. */
|
|
6
|
+
export const BALANCE_WATCH_LIFETIME_MS = 15 * 60_000;
|
|
7
|
+
/** Full retry schedules a returning host can trigger; later wakes read once. */
|
|
8
|
+
export const BALANCE_WATCH_MAX_SCHEDULED_RUNS = 5;
|
|
9
|
+
export function createBalanceWatch(options) {
|
|
10
|
+
const { credits } = options;
|
|
11
|
+
let baselineMicros = options.baselineMicros;
|
|
12
|
+
let runs = 0;
|
|
13
|
+
let running = false;
|
|
14
|
+
let finished;
|
|
15
|
+
const timers = new Set();
|
|
16
|
+
let resolveOutcome = () => { };
|
|
17
|
+
const outcome = new Promise((resolve) => {
|
|
18
|
+
resolveOutcome = resolve;
|
|
19
|
+
});
|
|
20
|
+
function finish(result) {
|
|
21
|
+
if (finished !== undefined)
|
|
22
|
+
return;
|
|
23
|
+
finished = result;
|
|
24
|
+
for (const timer of timers)
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
timers.clear();
|
|
27
|
+
resolveOutcome(result);
|
|
28
|
+
}
|
|
29
|
+
function wait(ms) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
timers.delete(timer);
|
|
33
|
+
resolve();
|
|
34
|
+
}, ms);
|
|
35
|
+
timers.add(timer);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// A failed read is not a verdict: the next attempt covers it, and the
|
|
39
|
+
// purchase itself already went through.
|
|
40
|
+
async function balanceRose() {
|
|
41
|
+
const read = await credits.read();
|
|
42
|
+
if (read.status === 'error')
|
|
43
|
+
return false;
|
|
44
|
+
const amountMicros = read.value.balance.amount_micros;
|
|
45
|
+
if (baselineMicros === undefined) {
|
|
46
|
+
baselineMicros = amountMicros;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return amountMicros > baselineMicros;
|
|
50
|
+
}
|
|
51
|
+
// Past the run cap a wake still reads once — the return after the actual
|
|
52
|
+
// payment must read no matter how often the host glanced back beforehand.
|
|
53
|
+
const isFinished = () => finished !== undefined;
|
|
54
|
+
async function run() {
|
|
55
|
+
running = true;
|
|
56
|
+
runs += 1;
|
|
57
|
+
const gaps = runs > BALANCE_WATCH_MAX_SCHEDULED_RUNS
|
|
58
|
+
? [0]
|
|
59
|
+
: BALANCE_WATCH_RETRY_GAPS_MS;
|
|
60
|
+
for (const gap of gaps) {
|
|
61
|
+
await wait(gap);
|
|
62
|
+
if (isFinished())
|
|
63
|
+
return;
|
|
64
|
+
if (await balanceRose()) {
|
|
65
|
+
finish('reconciled');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (isFinished())
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
running = false;
|
|
72
|
+
}
|
|
73
|
+
timers.add(setTimeout(() => finish('expired'), BALANCE_WATCH_LIFETIME_MS));
|
|
74
|
+
return {
|
|
75
|
+
wake: () => {
|
|
76
|
+
if (running || finished !== undefined)
|
|
77
|
+
return;
|
|
78
|
+
void run();
|
|
79
|
+
},
|
|
80
|
+
stop: () => finish('stopped'),
|
|
81
|
+
outcome
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contracts the billing core shares with its hosts. Dependency-neutral
|
|
3
|
+
* by design, the same way `sessionContracts` is: the transport and the typed
|
|
4
|
+
* operations depend on this module, never back on each other.
|
|
5
|
+
*
|
|
6
|
+
* Billing reports failures as coded results rather than thrown errors, so a
|
|
7
|
+
* caller cannot accidentally surface a server or payment-provider string to a
|
|
8
|
+
* user. The codes carry no server text; the copy that belongs to each code is
|
|
9
|
+
* owned by the billing core and localized by the host. The one server value
|
|
10
|
+
* that crosses this boundary is `serverCode`, a machine identifier and never
|
|
11
|
+
* copy: a command matches it against its own closed set, and a host stores it
|
|
12
|
+
* where it already keeps `WorkspaceApiError.code`. Its brand makes it
|
|
13
|
+
* unforgeable: only the response decoder mints one, and `unwrapServerCode`
|
|
14
|
+
* names the one sanctioned widening.
|
|
15
|
+
*/
|
|
16
|
+
import type { SessionClient } from '../session.js';
|
|
17
|
+
/**
|
|
18
|
+
* The session members the billing core reaches for. A host's client is typed
|
|
19
|
+
* for its own user, and the identity seam is contravariant in that user, so
|
|
20
|
+
* requiring the full client would reject every host whose user is more
|
|
21
|
+
* specific than the base.
|
|
22
|
+
*/
|
|
23
|
+
export type BillingSession = Pick<SessionClient, 'getSnapshot' | 'subscribe' | 'ensureFresh' | 'remint'>;
|
|
24
|
+
/**
|
|
25
|
+
* The failure buckets a billing request can produce, extracted from what the
|
|
26
|
+
* cloud app's `workspaceApi` callers actually distinguish today.
|
|
27
|
+
* REQUEST_FAILED is the transient bucket: 5xx, a network failure, an abort,
|
|
28
|
+
* and a timeout are one failure for a caller, exactly as they are in the
|
|
29
|
+
* session client's `TOKEN_EXCHANGE_FAILED`.
|
|
30
|
+
*/
|
|
31
|
+
export type BillingErrorCode =
|
|
32
|
+
/** Nobody is signed in, or the identity could not produce a token. */
|
|
33
|
+
'NOT_AUTHENTICATED'
|
|
34
|
+
/** A 401 that survived the one re-mint retry, or a 403. */
|
|
35
|
+
| 'ACCESS_DENIED' | 'NOT_FOUND'
|
|
36
|
+
/** A business-level 409 — never a missing customer, which self-heals. */
|
|
37
|
+
| 'CONFLICT'
|
|
38
|
+
/** The identity or workspace changed while the request was in flight. */
|
|
39
|
+
| 'SUPERSEDED' | 'REQUEST_FAILED'
|
|
40
|
+
/** A 2xx whose body does not match the generated contract. */
|
|
41
|
+
| 'MALFORMED_RESPONSE';
|
|
42
|
+
declare const billingServerCodeBrand: unique symbol;
|
|
43
|
+
/**
|
|
44
|
+
* An unbounded server string that is only ever a machine identifier. Nothing
|
|
45
|
+
* outside `readBillingErrorCode` mints one, so a command cannot invent a code
|
|
46
|
+
* to match. The value stays a `string` underneath: `matchesServerCode` and
|
|
47
|
+
* `unwrapServerCode` name the sanctioned uses rather than making the string
|
|
48
|
+
* unreadable.
|
|
49
|
+
*/
|
|
50
|
+
export type BillingServerCode = string & {
|
|
51
|
+
readonly [billingServerCodeBrand]: true;
|
|
52
|
+
};
|
|
53
|
+
export type BillingFailure = {
|
|
54
|
+
readonly status: 'error';
|
|
55
|
+
readonly code: BillingErrorCode;
|
|
56
|
+
/** Set only when the failure came from an HTTP response. */
|
|
57
|
+
readonly httpStatus?: number;
|
|
58
|
+
/**
|
|
59
|
+
* The coded `code` of a generated `ErrorResponse` body, when the server
|
|
60
|
+
* sent one. Its `message` is dropped on purpose: a command acts on codes
|
|
61
|
+
* it names, never on server text. Compare it through `matchesServerCode`,
|
|
62
|
+
* and widen it through `unwrapServerCode` at a host's error-store
|
|
63
|
+
* boundary. Never render it.
|
|
64
|
+
*/
|
|
65
|
+
readonly serverCode?: BillingServerCode;
|
|
66
|
+
};
|
|
67
|
+
/** Whether a failure carries the server code a command names. */
|
|
68
|
+
export declare function matchesServerCode(failure: Pick<BillingFailure, 'serverCode'>, code: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The one widening back to `string`, for a host storing the code beside the
|
|
71
|
+
* codes it already keeps. Rendering it is still a contract violation.
|
|
72
|
+
*/
|
|
73
|
+
export declare function unwrapServerCode(code: BillingServerCode): string;
|
|
74
|
+
export type BillingResult<T> = {
|
|
75
|
+
readonly status: 'ok';
|
|
76
|
+
readonly value: T;
|
|
77
|
+
} | BillingFailure;
|
|
78
|
+
interface BillingRequestBase {
|
|
79
|
+
/** Route below the host's billing base, e.g. `/billing/topup`. */
|
|
80
|
+
readonly route: string;
|
|
81
|
+
/**
|
|
82
|
+
* Present on a write whose replay the backend deduplicates. It is also
|
|
83
|
+
* what makes a 401 retry safe: see `createSessionBillingTransport`.
|
|
84
|
+
*/
|
|
85
|
+
readonly idempotencyKey?: string;
|
|
86
|
+
readonly signal?: AbortSignal;
|
|
87
|
+
/** Total budget for session minting, retries, and reading the response. */
|
|
88
|
+
readonly timeoutMs?: number;
|
|
89
|
+
}
|
|
90
|
+
export type BillingRequest = BillingRequestBase & ({
|
|
91
|
+
readonly method: 'GET';
|
|
92
|
+
readonly body?: never;
|
|
93
|
+
} | {
|
|
94
|
+
readonly method: 'POST';
|
|
95
|
+
readonly body?: unknown;
|
|
96
|
+
});
|
|
97
|
+
export interface BillingHttpResponse {
|
|
98
|
+
readonly httpStatus: number;
|
|
99
|
+
readonly body: unknown;
|
|
100
|
+
/** True when a 401 could not be retried because the write was not replayable. */
|
|
101
|
+
readonly authenticationRetrySkipped?: true;
|
|
102
|
+
/**
|
|
103
|
+
* True when the transport holds no credential of its own to re-prove with,
|
|
104
|
+
* so a 401 is the host's session ending rather than a refusal.
|
|
105
|
+
*/
|
|
106
|
+
readonly authenticationNotRenewable?: true;
|
|
107
|
+
/**
|
|
108
|
+
* Response header reader. The capability revision a mutation reports
|
|
109
|
+
* (`X-Capability-Revision`) reaches the capabilities cache through this,
|
|
110
|
+
* mirroring the cloud app's `attachCapabilityRevisionInterceptor`. It is
|
|
111
|
+
* absent whenever CORS is bypassed, so every reader treats a missing
|
|
112
|
+
* header as "no revision reported" rather than assuming presence.
|
|
113
|
+
*/
|
|
114
|
+
readonly header: (name: string) => string | null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One request, credentials included. An HTTP answer of any status is an `ok`
|
|
118
|
+
* result — only a failure that produced no response at all (no session,
|
|
119
|
+
* network, abort, timeout) is an `error`, so the typed operations above it
|
|
120
|
+
* own the status-to-code mapping in one place.
|
|
121
|
+
*/
|
|
122
|
+
export type BillingTransport = (request: BillingRequest) => Promise<BillingResult<BillingHttpResponse>>;
|
|
123
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Whether a failure carries the server code a command names. */
|
|
2
|
+
export function matchesServerCode(failure, code) {
|
|
3
|
+
return failure.serverCode === code;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The one widening back to `string`, for a host storing the code beside the
|
|
7
|
+
* codes it already keeps. Rendering it is still a contract violation.
|
|
8
|
+
*/
|
|
9
|
+
export function unwrapServerCode(code) {
|
|
10
|
+
return code;
|
|
11
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BillingServerCode } from './billingContracts.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `code` of a generated `ErrorResponse` body, or nothing. The body's
|
|
4
|
+
* `message` is read by the schema and discarded here, so it cannot travel
|
|
5
|
+
* further into the SDK. This decode is the only place a `BillingServerCode`
|
|
6
|
+
* is minted, and the brand it carries is erased at runtime.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readBillingErrorCode(body: unknown): BillingServerCode | undefined;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { zErrorResponse } from '@comfyorg/ingest-types/zod';
|
|
2
|
+
/**
|
|
3
|
+
* The `code` of a generated `ErrorResponse` body, or nothing. The body's
|
|
4
|
+
* `message` is read by the schema and discarded here, so it cannot travel
|
|
5
|
+
* further into the SDK. This decode is the only place a `BillingServerCode`
|
|
6
|
+
* is minted, and the brand it carries is erased at runtime.
|
|
7
|
+
*/
|
|
8
|
+
export function readBillingErrorCode(body) {
|
|
9
|
+
const parsed = zErrorResponse.safeParse(body);
|
|
10
|
+
return parsed.success ? parsed.data.code : undefined;
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scope port: the one thing the billing core needs to know about the
|
|
3
|
+
* identity it runs as — which user, which workspace, which role — and when
|
|
4
|
+
* that changes. A host that holds a browser session client adapts it with
|
|
5
|
+
* `sessionBillingScopeSource`; a host whose credentials live on its own
|
|
6
|
+
* server reads the scope out of its bootstrap payload instead, and never
|
|
7
|
+
* hands the core a session client it does not have.
|
|
8
|
+
*/
|
|
9
|
+
import type { BillingSession } from './billingContracts.js';
|
|
10
|
+
import type { AccountCredential } from '../sessionContracts.js';
|
|
11
|
+
export interface BillingScope {
|
|
12
|
+
readonly userId: string;
|
|
13
|
+
readonly workspaceId: string;
|
|
14
|
+
readonly role: AccountCredential['role'];
|
|
15
|
+
}
|
|
16
|
+
export interface BillingScopeContext {
|
|
17
|
+
readonly scope: BillingScope;
|
|
18
|
+
readonly generation: number;
|
|
19
|
+
}
|
|
20
|
+
export interface BillingScopeTracker {
|
|
21
|
+
capture: () => BillingScopeContext | undefined;
|
|
22
|
+
isCurrent: (context: BillingScopeContext) => boolean;
|
|
23
|
+
dispose: () => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where the core learns its scope. `getScope` is undefined whenever no scope
|
|
27
|
+
* is established — signed out, or not settled yet — and `subscribe` reports
|
|
28
|
+
* that anything about it may have changed.
|
|
29
|
+
*/
|
|
30
|
+
export interface BillingScopeSource {
|
|
31
|
+
readonly getScope: () => BillingScope | undefined;
|
|
32
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function sameBillingScope(a: BillingScope, b: BillingScope): boolean;
|
|
35
|
+
type BillingScopeSession = Pick<BillingSession, 'getSnapshot' | 'subscribe'>;
|
|
36
|
+
/** The scope a workspace session client is currently minted for. */
|
|
37
|
+
export declare function sessionBillingScopeSource(session: BillingScopeSession): BillingScopeSource;
|
|
38
|
+
export declare function createBillingScopeTracker(source: BillingScopeSource, onChange: () => void): BillingScopeTracker;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function sameBillingScope(a, b) {
|
|
2
|
+
return (a.userId === b.userId &&
|
|
3
|
+
a.workspaceId === b.workspaceId &&
|
|
4
|
+
a.role === b.role);
|
|
5
|
+
}
|
|
6
|
+
/** The scope a workspace session client is currently minted for. */
|
|
7
|
+
export function sessionBillingScopeSource(session) {
|
|
8
|
+
return {
|
|
9
|
+
getScope: () => readSessionScope(session),
|
|
10
|
+
subscribe: (listener) => session.subscribe(listener)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function createBillingScopeTracker(source, onChange) {
|
|
14
|
+
let scope = source.getScope();
|
|
15
|
+
let generation = 0;
|
|
16
|
+
const unsubscribe = source.subscribe(() => {
|
|
17
|
+
const next = source.getScope();
|
|
18
|
+
if ((scope === undefined && next === undefined) ||
|
|
19
|
+
(scope !== undefined &&
|
|
20
|
+
next !== undefined &&
|
|
21
|
+
sameBillingScope(scope, next))) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
scope = next;
|
|
25
|
+
generation++;
|
|
26
|
+
onChange();
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
capture: () => (scope === undefined ? undefined : { scope, generation }),
|
|
30
|
+
isCurrent: (context) => context.generation === generation &&
|
|
31
|
+
scope !== undefined &&
|
|
32
|
+
sameBillingScope(context.scope, scope),
|
|
33
|
+
dispose: unsubscribe
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readSessionScope(session) {
|
|
37
|
+
const state = session.getSnapshot();
|
|
38
|
+
if (state.user === null || state.session === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
return {
|
|
41
|
+
userId: state.user.uid,
|
|
42
|
+
workspaceId: state.session.workspace.id,
|
|
43
|
+
role: state.session.role
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capabilities read: a server-authoritative answer to "what may this actor
|
|
3
|
+
* be offered for this workspace", cached per scope.
|
|
4
|
+
*
|
|
5
|
+
* The SDK never derives a capability. It decodes what the server resolved and
|
|
6
|
+
* caches it under the scope it was resolved for, so no entitlement rule is
|
|
7
|
+
* reimplemented here and none can drift from the backend's.
|
|
8
|
+
*
|
|
9
|
+
* Freshness is pull-based on purpose: a snapshot carries the instant it stops
|
|
10
|
+
* being fresh, and a caller learns it is stale by asking. The production
|
|
11
|
+
* composable's timer, its visibility gating, and its retry backoff are all
|
|
12
|
+
* push concerns that need a document and a scheduler, so they belong to the
|
|
13
|
+
* view package that has both — not to a framework-free core.
|
|
14
|
+
*/
|
|
15
|
+
import { zBillingCapabilities, zBillingCapabilityRolloutDefaults } from '@comfyorg/ingest-types/zod';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import type { BillingResult, BillingTransport } from './billingContracts.js';
|
|
18
|
+
import type { BillingScope, BillingScopeSource } from './billingScope.js';
|
|
19
|
+
import type { CapabilityDenials } from './capabilityDenials.js';
|
|
20
|
+
export declare const CAPABILITIES_ROUTE = "/billing/capabilities";
|
|
21
|
+
export declare const CAPABILITY_REVISION_HEADER = "X-Capability-Revision";
|
|
22
|
+
export type BillingCapabilities = z.infer<typeof zBillingCapabilities>;
|
|
23
|
+
export type CapabilityRolloutDefaults = z.infer<typeof zBillingCapabilityRolloutDefaults>;
|
|
24
|
+
export type CapabilityScope = BillingScope;
|
|
25
|
+
export interface CapabilitiesSnapshot {
|
|
26
|
+
readonly capabilities: BillingCapabilities;
|
|
27
|
+
/**
|
|
28
|
+
* Present only for a capability the server refused and explained. An absent
|
|
29
|
+
* key never implies the capability was granted — read `capabilities`.
|
|
30
|
+
*/
|
|
31
|
+
readonly denials: CapabilityDenials;
|
|
32
|
+
/** True values are rollout guidance, not evidence a write will succeed. */
|
|
33
|
+
readonly rolloutDefaultsApplied: CapabilityRolloutDefaults;
|
|
34
|
+
readonly revision: number;
|
|
35
|
+
readonly scope: CapabilityScope;
|
|
36
|
+
/** ms since epoch, after which `read` refetches rather than serving this. */
|
|
37
|
+
readonly freshUntil: number;
|
|
38
|
+
}
|
|
39
|
+
export interface CapabilitiesReadOptions {
|
|
40
|
+
readonly signal?: AbortSignal;
|
|
41
|
+
/** Bypasses a fresh cached snapshot; still joins an in-flight read. */
|
|
42
|
+
readonly forceRefresh?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface CapabilitiesReader {
|
|
45
|
+
read: (options?: CapabilitiesReadOptions) => Promise<BillingResult<CapabilitiesSnapshot>>;
|
|
46
|
+
/** The cached snapshot for the current scope, fresh or not. */
|
|
47
|
+
getSnapshot: () => CapabilitiesSnapshot | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Marks the cache stale for a revision a **mutation** reported. A read's own
|
|
50
|
+
* revision must never be fed back here: the capabilities response echoes the
|
|
51
|
+
* header it was built with, so republishing it would mark that read stale and
|
|
52
|
+
* refetch forever.
|
|
53
|
+
*/
|
|
54
|
+
invalidate: (revision?: number) => void;
|
|
55
|
+
/** Detaches the scope subscription. */
|
|
56
|
+
dispose: () => void;
|
|
57
|
+
}
|
|
58
|
+
export interface CapabilitiesReaderOptions {
|
|
59
|
+
readonly transport: BillingTransport;
|
|
60
|
+
/** Where the core learns which user, workspace, and role it runs as. */
|
|
61
|
+
readonly scopeSource: BillingScopeSource;
|
|
62
|
+
readonly now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads the revision a mutation reported. Absent whenever CORS is bypassed
|
|
66
|
+
* (local desktop origins), so a missing header is "nothing reported" rather
|
|
67
|
+
* than a reason to invalidate.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readCapabilityRevision(header: (name: string) => string | null): number | undefined;
|
|
70
|
+
export declare function createCapabilitiesReader(options: CapabilitiesReaderOptions): CapabilitiesReader;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capabilities read: a server-authoritative answer to "what may this actor
|
|
3
|
+
* be offered for this workspace", cached per scope.
|
|
4
|
+
*
|
|
5
|
+
* The SDK never derives a capability. It decodes what the server resolved and
|
|
6
|
+
* caches it under the scope it was resolved for, so no entitlement rule is
|
|
7
|
+
* reimplemented here and none can drift from the backend's.
|
|
8
|
+
*
|
|
9
|
+
* Freshness is pull-based on purpose: a snapshot carries the instant it stops
|
|
10
|
+
* being fresh, and a caller learns it is stale by asking. The production
|
|
11
|
+
* composable's timer, its visibility gating, and its retry backoff are all
|
|
12
|
+
* push concerns that need a document and a scheduler, so they belong to the
|
|
13
|
+
* view package that has both — not to a framework-free core.
|
|
14
|
+
*/
|
|
15
|
+
import { zBillingCapabilities, zBillingCapabilityRolloutDefaults, zBillingCapabilityScope } from '@comfyorg/ingest-types/zod';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { sameBillingScope } from './billingScope.js';
|
|
18
|
+
import { decodeCapabilityDenials } from './capabilityDenials.js';
|
|
19
|
+
import { createScopedReader } from './scopedReader.js';
|
|
20
|
+
export const CAPABILITIES_ROUTE = '/billing/capabilities';
|
|
21
|
+
/** Production's own budget for this read (`workspaceApi.getBillingCapabilities`). */
|
|
22
|
+
const CAPABILITIES_TIMEOUT_MS = 10_000;
|
|
23
|
+
/**
|
|
24
|
+
* A remaining lifetime below the floor means the client clock disagrees with
|
|
25
|
+
* the server's, so `expires_at` cannot pace anything and the fixed interval is
|
|
26
|
+
* used instead. The ceiling bounds the opposite skew, where a lagging clock
|
|
27
|
+
* reads `expires_at` as far in the future. Both bounds, and the fallback, are
|
|
28
|
+
* the production composable's.
|
|
29
|
+
*/
|
|
30
|
+
const MIN_FRESH_MS = 5_000;
|
|
31
|
+
const MAX_FRESH_MS = 60 * 60 * 1000;
|
|
32
|
+
const FALLBACK_FRESH_MS = 60_000;
|
|
33
|
+
export const CAPABILITY_REVISION_HEADER = 'X-Capability-Revision';
|
|
34
|
+
/**
|
|
35
|
+
* The generated contract minus `denied_reasons`, which is decoded separately
|
|
36
|
+
* and leniently. `revision` is read as a number rather than the generated
|
|
37
|
+
* bigint: the server bounds it to the JavaScript-safe range precisely so
|
|
38
|
+
* clients can compare it as one.
|
|
39
|
+
*/
|
|
40
|
+
const CapabilitiesBodySchema = z.object({
|
|
41
|
+
capabilities: zBillingCapabilities,
|
|
42
|
+
expires_at: z.string(),
|
|
43
|
+
resolved_for: zBillingCapabilityScope,
|
|
44
|
+
revision: z.number(),
|
|
45
|
+
rollout_defaults_applied: zBillingCapabilityRolloutDefaults
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Reads the revision a mutation reported. Absent whenever CORS is bypassed
|
|
49
|
+
* (local desktop origins), so a missing header is "nothing reported" rather
|
|
50
|
+
* than a reason to invalidate.
|
|
51
|
+
*/
|
|
52
|
+
export function readCapabilityRevision(header) {
|
|
53
|
+
const raw = header(CAPABILITY_REVISION_HEADER);
|
|
54
|
+
if (raw === null)
|
|
55
|
+
return undefined;
|
|
56
|
+
const revision = Number(raw);
|
|
57
|
+
return Number.isSafeInteger(revision) && revision > 0 ? revision : undefined;
|
|
58
|
+
}
|
|
59
|
+
function freshUntilFrom(expiresAt, now) {
|
|
60
|
+
// Date.parse yields NaN on an unparseable value; NaN fails the comparison
|
|
61
|
+
// and lands on the fixed interval, which is the intended branch.
|
|
62
|
+
const remaining = Date.parse(expiresAt) - now;
|
|
63
|
+
if (remaining >= MIN_FRESH_MS) {
|
|
64
|
+
return now + Math.min(remaining, MAX_FRESH_MS);
|
|
65
|
+
}
|
|
66
|
+
return now + FALLBACK_FRESH_MS;
|
|
67
|
+
}
|
|
68
|
+
function freshSnapshot(snapshot, scope, forceRefresh, now) {
|
|
69
|
+
if (forceRefresh || snapshot === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (!sameBillingScope(snapshot.scope, scope))
|
|
72
|
+
return undefined;
|
|
73
|
+
return snapshot.freshUntil > now ? snapshot : undefined;
|
|
74
|
+
}
|
|
75
|
+
function snapshotAfterInvalidation(snapshot, invalidated) {
|
|
76
|
+
return invalidated ? { ...snapshot, freshUntil: 0 } : snapshot;
|
|
77
|
+
}
|
|
78
|
+
export function createCapabilitiesReader(options) {
|
|
79
|
+
const { transport, scopeSource, now = Date.now } = options;
|
|
80
|
+
function projectCapabilities(response, scope) {
|
|
81
|
+
const { data, body, httpStatus } = response;
|
|
82
|
+
// Capabilities resolve per (user, workspace), so both halves have to
|
|
83
|
+
// match. Checking only the workspace would accept another member's answer
|
|
84
|
+
// for the workspace the caller is in and publish it as the caller's own —
|
|
85
|
+
// an actor with fewer or greater rights deciding what this one is offered.
|
|
86
|
+
// Either mismatch is a contract violation rather than an answer about this
|
|
87
|
+
// scope, so it joins the transient bucket instead of being cached. The
|
|
88
|
+
// ingest service makes the same two-part check against its own upstream
|
|
89
|
+
// before it forwards a response.
|
|
90
|
+
const resolved = data.resolved_for;
|
|
91
|
+
if (resolved.user_id !== scope.userId ||
|
|
92
|
+
resolved.workspace_id !== scope.workspaceId) {
|
|
93
|
+
return { status: 'error', code: 'REQUEST_FAILED', httpStatus };
|
|
94
|
+
}
|
|
95
|
+
const denials = decodeCapabilityDenials(body.denied_reasons);
|
|
96
|
+
return {
|
|
97
|
+
status: 'ok',
|
|
98
|
+
value: {
|
|
99
|
+
capabilities: data.capabilities,
|
|
100
|
+
denials,
|
|
101
|
+
rolloutDefaultsApplied: data.rollout_defaults_applied,
|
|
102
|
+
revision: data.revision,
|
|
103
|
+
scope,
|
|
104
|
+
freshUntil: freshUntilFrom(data.expires_at, now())
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const reader = createScopedReader({
|
|
109
|
+
transport,
|
|
110
|
+
scopeSource,
|
|
111
|
+
route: CAPABILITIES_ROUTE,
|
|
112
|
+
parse: (body) => CapabilitiesBodySchema.safeParse(body),
|
|
113
|
+
timeoutMs: () => CAPABILITIES_TIMEOUT_MS,
|
|
114
|
+
cached: (snapshot, scope, readOptions) => freshSnapshot(snapshot, scope, readOptions?.forceRefresh === true, now()),
|
|
115
|
+
project: projectCapabilities,
|
|
116
|
+
// A mutation committed while this read was in flight, and the revision the
|
|
117
|
+
// read returns cannot rule out having missed it: the server mints that
|
|
118
|
+
// number when it serializes, not when it read. The value is still the best
|
|
119
|
+
// available, so it is published — as already stale, so the next read
|
|
120
|
+
// refetches instead of serving it.
|
|
121
|
+
publish: snapshotAfterInvalidation
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
read: reader.read,
|
|
125
|
+
getSnapshot: reader.getSnapshot,
|
|
126
|
+
invalidate: (revision) => {
|
|
127
|
+
reader.fenceInFlight();
|
|
128
|
+
const snapshot = reader.getSnapshot();
|
|
129
|
+
if (snapshot === undefined)
|
|
130
|
+
return;
|
|
131
|
+
if (revision !== undefined && snapshot.revision === revision)
|
|
132
|
+
return;
|
|
133
|
+
reader.setSnapshot({ ...snapshot, freshUntil: 0 });
|
|
134
|
+
},
|
|
135
|
+
dispose: reader.dispose
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy branches the server names, plus the bucket this module adds for
|
|
3
|
+
* a value it does not recognize. `not_a_member` is unreachable through the
|
|
4
|
+
* ingest endpoint — a non-member is answered 403 before capabilities resolve —
|
|
5
|
+
* and is carried anyway because the billing-api endpoint can still emit it.
|
|
6
|
+
*/
|
|
7
|
+
export type CapabilityDenialReason = 'not_a_member' | 'not_workspace_owner' | 'tier_not_self_serve'
|
|
8
|
+
/** A subscription row reserved before payment that never began. */
|
|
9
|
+
| 'subscription_not_started'
|
|
10
|
+
/** The subscription already has a successor scheduled at a billing boundary. */
|
|
11
|
+
| 'subscription_change_in_progress'
|
|
12
|
+
/** The server has no guidance for the row — distinct from a deliberate refusal. */
|
|
13
|
+
| 'subscription_status_unrecognized'
|
|
14
|
+
/**
|
|
15
|
+
* A reason outside the set above. Kept as a reason rather than dropped so a
|
|
16
|
+
* refusal the server explained still reads as explained; the copy it maps to
|
|
17
|
+
* is the generic one.
|
|
18
|
+
*/
|
|
19
|
+
| 'unspecified';
|
|
20
|
+
/**
|
|
21
|
+
* Only `can_subscribe_self_serve` carries a reason today. The record is open
|
|
22
|
+
* because the server adds keys as policy grows, and a client that rejected an
|
|
23
|
+
* unknown key would fail the whole read over a field it does not use.
|
|
24
|
+
*/
|
|
25
|
+
export type CapabilityDenials = Readonly<Partial<Record<string, CapabilityDenialReason>>>;
|
|
26
|
+
export declare function decodeCapabilityDenials(raw: unknown): CapabilityDenials;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a capability resolved false, decoded from the capabilities read.
|
|
3
|
+
*
|
|
4
|
+
* The server's contract runs one way only, and every reader here depends on
|
|
5
|
+
* it: a reason is present only alongside `capabilities.<key> === false`, so
|
|
6
|
+
* presence implies refusal — but absence implies nothing at all. A capability
|
|
7
|
+
* with no reason may be granted, may be refused by a billing-api that predates
|
|
8
|
+
* the field, or may be refused for a reason the server declined to forward.
|
|
9
|
+
* `capabilities` stays the only authority for what a client may offer; these
|
|
10
|
+
* keys only ever choose the wording for a refusal already decided there.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
const KNOWN_REASONS = new Set([
|
|
14
|
+
'not_a_member',
|
|
15
|
+
'not_workspace_owner',
|
|
16
|
+
'tier_not_self_serve',
|
|
17
|
+
'subscription_not_started',
|
|
18
|
+
'subscription_change_in_progress',
|
|
19
|
+
'subscription_status_unrecognized'
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* Deliberately lenient: values are read as plain strings and narrowed here,
|
|
23
|
+
* never as a generated enum. A strict enum parse would make the entire
|
|
24
|
+
* capabilities read malformed the first time the server names a policy branch
|
|
25
|
+
* this client predates — turning a copy detail into a total loss of the
|
|
26
|
+
* capability set, which is the one part of the response that gates UI.
|
|
27
|
+
*
|
|
28
|
+
* This mirrors what the ingest service already does on its own side, where an
|
|
29
|
+
* unrecognized reason is dropped rather than forwarded.
|
|
30
|
+
*/
|
|
31
|
+
const DeniedReasonsSchema = z.record(z.string(), z.string()).optional();
|
|
32
|
+
export function decodeCapabilityDenials(raw) {
|
|
33
|
+
const parsed = DeniedReasonsSchema.safeParse(raw);
|
|
34
|
+
if (!parsed.success || parsed.data === undefined)
|
|
35
|
+
return {};
|
|
36
|
+
const denials = {};
|
|
37
|
+
for (const [capability, reason] of Object.entries(parsed.data)) {
|
|
38
|
+
denials[capability] = KNOWN_REASONS.has(reason)
|
|
39
|
+
? reason
|
|
40
|
+
: 'unspecified';
|
|
41
|
+
}
|
|
42
|
+
return denials;
|
|
43
|
+
}
|