@comfyorg/account-core 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/billing/balanceWatch.d.ts +35 -0
  3. package/dist/core/billing/balanceWatch.js +83 -0
  4. package/dist/core/billing/billingContracts.d.ts +123 -0
  5. package/dist/core/billing/billingContracts.js +11 -0
  6. package/dist/core/billing/billingErrorBody.d.ts +8 -0
  7. package/dist/core/billing/billingErrorBody.js +11 -0
  8. package/dist/core/billing/billingScope.d.ts +39 -0
  9. package/dist/core/billing/billingScope.js +45 -0
  10. package/dist/core/billing/capabilities.d.ts +70 -0
  11. package/dist/core/billing/capabilities.js +137 -0
  12. package/dist/core/billing/capabilityDenials.d.ts +26 -0
  13. package/dist/core/billing/capabilityDenials.js +43 -0
  14. package/dist/core/billing/challengeDriver.d.ts +22 -0
  15. package/dist/core/billing/challengeDriver.js +38 -0
  16. package/dist/core/billing/credentialedTransport.d.ts +35 -0
  17. package/dist/core/billing/credentialedTransport.js +53 -0
  18. package/dist/core/billing/credits.d.ts +43 -0
  19. package/dist/core/billing/credits.js +29 -0
  20. package/dist/core/billing/httpStatus.d.ts +7 -0
  21. package/dist/core/billing/httpStatus.js +25 -0
  22. package/dist/core/billing/index.d.ts +52 -0
  23. package/dist/core/billing/index.js +23 -0
  24. package/dist/core/billing/operationLifecycle.d.ts +77 -0
  25. package/dist/core/billing/operationLifecycle.js +547 -0
  26. package/dist/core/billing/operationPointer.d.ts +48 -0
  27. package/dist/core/billing/operationPointer.js +89 -0
  28. package/dist/core/billing/operationPolicy.d.ts +26 -0
  29. package/dist/core/billing/operationPolicy.js +51 -0
  30. package/dist/core/billing/operationState.d.ts +128 -0
  31. package/dist/core/billing/operationState.js +187 -0
  32. package/dist/core/billing/paymentCopy.d.ts +18 -0
  33. package/dist/core/billing/paymentCopy.js +47 -0
  34. package/dist/core/billing/paymentMethods.d.ts +40 -0
  35. package/dist/core/billing/paymentMethods.js +37 -0
  36. package/dist/core/billing/paymentProjection.d.ts +34 -0
  37. package/dist/core/billing/paymentProjection.js +56 -0
  38. package/dist/core/billing/plans.d.ts +44 -0
  39. package/dist/core/billing/plans.js +29 -0
  40. package/dist/core/billing/presentation.d.ts +20 -0
  41. package/dist/core/billing/presentation.js +10 -0
  42. package/dist/core/billing/scopedReader.d.ts +61 -0
  43. package/dist/core/billing/scopedReader.js +108 -0
  44. package/dist/core/billing/sharedRead.d.ts +33 -0
  45. package/dist/core/billing/sharedRead.js +59 -0
  46. package/dist/core/billing/status.d.ts +27 -0
  47. package/dist/core/billing/status.js +21 -0
  48. package/dist/core/billing/subscriptionCommands.d.ts +367 -0
  49. package/dist/core/billing/subscriptionCommands.js +263 -0
  50. package/dist/core/billing/topup.d.ts +113 -0
  51. package/dist/core/billing/topup.js +209 -0
  52. package/dist/core/billing/transport.d.ts +14 -0
  53. package/dist/core/billing/transport.js +106 -0
  54. package/dist/core/billing/transportExchange.d.ts +24 -0
  55. package/dist/core/billing/transportExchange.js +79 -0
  56. package/dist/core/boundedOperation.d.ts +16 -0
  57. package/dist/core/boundedOperation.js +12 -0
  58. package/dist/core/credentialCache.d.ts +41 -0
  59. package/dist/core/credentialCache.js +97 -0
  60. package/dist/core/customerRecovery.d.ts +39 -0
  61. package/dist/core/customerRecovery.js +79 -0
  62. package/dist/core/exchange.d.ts +50 -0
  63. package/dist/core/exchange.js +137 -0
  64. package/dist/core/identity.d.ts +21 -0
  65. package/dist/core/identity.js +16 -0
  66. package/dist/core/mintCoordinator.d.ts +18 -0
  67. package/dist/core/mintCoordinator.js +31 -0
  68. package/dist/core/refreshScheduler.d.ts +46 -0
  69. package/dist/core/refreshScheduler.js +262 -0
  70. package/dist/core/session.d.ts +146 -0
  71. package/dist/core/session.js +312 -0
  72. package/dist/core/sessionContracts.d.ts +135 -0
  73. package/dist/core/sessionContracts.js +9 -0
  74. package/dist/core/sessionState.d.ts +118 -0
  75. package/dist/core/sessionState.js +235 -0
  76. package/dist/firebase/index.d.ts +53 -0
  77. package/dist/firebase/index.js +74 -0
  78. package/dist/firebaseAuthError.d.ts +52 -0
  79. package/dist/firebaseAuthError.js +88 -0
  80. package/dist/loadExternalScript.d.ts +7 -0
  81. package/dist/loadExternalScript.js +88 -0
  82. package/dist/provisioning.d.ts +53 -0
  83. package/dist/provisioning.js +76 -0
  84. package/dist/redirect.d.ts +13 -0
  85. package/dist/redirect.js +38 -0
  86. package/dist/signInSchemas.d.ts +88 -0
  87. package/dist/signInSchemas.js +71 -0
  88. package/dist/telemetry.d.ts +39 -0
  89. package/dist/telemetry.js +26 -0
  90. package/dist/testing.d.ts +7 -0
  91. package/dist/testing.js +3 -0
  92. package/dist/turnstile.d.ts +14 -0
  93. package/dist/turnstile.js +17 -0
  94. package/dist/turnstileScript.d.ts +18 -0
  95. package/dist/turnstileScript.js +6 -0
  96. package/dist/web/crossTabRefresh.d.ts +18 -0
  97. package/dist/web/crossTabRefresh.js +121 -0
  98. package/dist/webviewDetection.d.ts +5 -0
  99. package/dist/webviewDetection.js +62 -0
  100. package/package.json +114 -0
@@ -0,0 +1,113 @@
1
+ import type { BillingFailure, BillingTransport } from './billingContracts.js';
2
+ import type { CapabilitiesReader } from './capabilities.js';
3
+ import type { CapabilityDenialReason } from './capabilityDenials.js';
4
+ import type { CreditsReader } from './credits.js';
5
+ import type { BillingOperationLifecycle } from './operationLifecycle.js';
6
+ import type { BillingOperationState, FailedBillingOperation } from './operationState.js';
7
+ export declare const TOPUP_ROUTE = "/billing/topup";
8
+ /**
9
+ * The hosted variant: a provider-hosted Checkout session with no operation to
10
+ * observe. The purchase happens on the returned page and the credits land
11
+ * through a webhook, so completion is the host's return plus a balance watch.
12
+ */
13
+ export declare const TOPUP_CHECKOUT_ROUTE = "/billing/topup/checkout";
14
+ type OperationIn<P extends BillingOperationState['phase']> = Extract<BillingOperationState, {
15
+ readonly phase: P;
16
+ }>;
17
+ export interface TopupSucceeded {
18
+ readonly status: 'ok';
19
+ readonly operation: OperationIn<'succeeded'>;
20
+ /**
21
+ * True once the balance read after settlement exceeds the one read before
22
+ * the command. False is "payment received, credits pending": the purchase
23
+ * settled and the balance has not caught up, or no baseline was readable.
24
+ */
25
+ readonly creditsReconciled: boolean;
26
+ }
27
+ export interface TopupDeclined {
28
+ readonly status: 'declined';
29
+ readonly operation: FailedBillingOperation;
30
+ }
31
+ /** The lifecycle stopped observing without a verdict; the id is the host's handle to support. */
32
+ export interface TopupUnsettled {
33
+ readonly status: 'unsettled';
34
+ readonly operation: OperationIn<'timed_out' | 'reconciliation_needed'>;
35
+ }
36
+ /** The server resolved `can_top_up` false for this scope; nothing was sent. */
37
+ export interface TopupDenied {
38
+ readonly status: 'error';
39
+ readonly code: 'ACCESS_DENIED';
40
+ readonly denial?: CapabilityDenialReason;
41
+ }
42
+ /** The top-up route is not deployed on this backend. */
43
+ export interface TopupNotAvailable {
44
+ readonly status: 'error';
45
+ readonly code: 'NOT_AVAILABLE';
46
+ }
47
+ export interface TopupNoPaymentMethod {
48
+ readonly status: 'error';
49
+ readonly code: 'NO_PAYMENT_METHOD';
50
+ readonly recoveryAction: 'replace_payment_method';
51
+ }
52
+ /** The amount fails the generated request contract; nothing was sent. */
53
+ export interface TopupInvalidAmount {
54
+ readonly status: 'error';
55
+ readonly code: 'INVALID_AMOUNT';
56
+ }
57
+ export type TopupFailure = BillingFailure | TopupDenied | TopupNotAvailable | TopupNoPaymentMethod | TopupInvalidAmount;
58
+ export type TopupResult = TopupSucceeded | TopupDeclined | TopupUnsettled | TopupFailure;
59
+ /** The return URL fails the generated request contract; nothing was sent. */
60
+ export interface TopupInvalidReturnUrl {
61
+ readonly status: 'error';
62
+ readonly code: 'INVALID_RETURN_URL';
63
+ }
64
+ /**
65
+ * A hosted Checkout session the host now sends the customer to. There is no
66
+ * operation and none is invented: the host executes its `OpenUrlMode` on
67
+ * `url`, and on return arms `createBalanceWatch` with `baselineMicros` to
68
+ * learn that the credits landed.
69
+ */
70
+ export interface HostedTopupCheckout {
71
+ readonly status: 'ok';
72
+ /** The provider-hosted Checkout page; https only. */
73
+ readonly url: string;
74
+ /** The provider's session id, for support and log correlation. */
75
+ readonly sessionId?: string;
76
+ /** The balance read before the session was created; absent when that read failed. */
77
+ readonly baselineMicros?: number;
78
+ }
79
+ export type HostedTopupCheckoutFailure = BillingFailure | TopupDenied | TopupNotAvailable | TopupInvalidAmount | TopupInvalidReturnUrl;
80
+ export type HostedTopupCheckoutResult = HostedTopupCheckout | HostedTopupCheckoutFailure;
81
+ export interface CreateHostedTopupCheckoutInput {
82
+ readonly amountCents: number;
83
+ /** Where the provider returns the customer; the backend allowlists its origin. */
84
+ readonly returnUrl: string;
85
+ readonly signal?: AbortSignal;
86
+ }
87
+ export interface CreateTopupCheckoutInput {
88
+ readonly amountCents: number;
89
+ /**
90
+ * Releases the capability read and the POST. Once the operation is adopted
91
+ * the lifecycle owns it, and the wait follows it to a terminal state.
92
+ */
93
+ readonly signal?: AbortSignal;
94
+ }
95
+ export interface TopupCommandOptions {
96
+ readonly transport: BillingTransport;
97
+ readonly lifecycle: Pick<BillingOperationLifecycle, 'begin' | 'settled'>;
98
+ readonly capabilities: Pick<CapabilitiesReader, 'read' | 'invalidate'>;
99
+ readonly credits: Pick<CreditsReader, 'read'>;
100
+ /** One key per POST attempt: the backend dedupes on it and the transport replays a 401 under it. */
101
+ readonly idempotencyKey?: () => string;
102
+ }
103
+ export interface TopupCommand {
104
+ createTopupCheckout: (input: CreateTopupCheckoutInput) => Promise<TopupResult>;
105
+ /**
106
+ * The route Workshop and Platform ship on today. Unlike
107
+ * `createTopupCheckout` it resolves as soon as the session exists, because
108
+ * nothing about the purchase can be observed until the customer returns.
109
+ */
110
+ createHostedTopupCheckout: (input: CreateHostedTopupCheckoutInput) => Promise<HostedTopupCheckoutResult>;
111
+ }
112
+ export declare function createTopupCommand(options: TopupCommandOptions): TopupCommand;
113
+ export {};
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The top-up command: one credit purchase mapped onto the shared billing
3
+ * lifecycle. This module owns the request and response shapes and nothing
4
+ * else — the status-read gate, single-flight, polling, routing, the pointer,
5
+ * failure meaning, and telemetry all come from the lifecycle it runs on.
6
+ *
7
+ * The result is a command-level union rather than an extension of
8
+ * `BillingErrorCode`: what a top-up can conclude (refused by capability,
9
+ * declined by the provider, not available on this backend) is the command's
10
+ * own vocabulary, and the terminal operation is embedded whenever one was
11
+ * observed so a host reads the coded decline and recovery action from it.
12
+ */
13
+ import { zCreateTopupCheckoutRequest, zCreateTopupCheckoutResponse, zCreateTopupRequest, zCreateTopupResponse } from '@comfyorg/ingest-types/zod';
14
+ import { matchesServerCode } from './billingContracts.js';
15
+ import { isTerminal, validateActionUrl } from './operationState.js';
16
+ import { readValidatedBillingResponse } from './sharedRead.js';
17
+ export const TOPUP_ROUTE = '/billing/topup';
18
+ /**
19
+ * The hosted variant: a provider-hosted Checkout session with no operation to
20
+ * observe. The purchase happens on the returned page and the credits land
21
+ * through a webhook, so completion is the host's return plus a balance watch.
22
+ */
23
+ export const TOPUP_CHECKOUT_ROUTE = '/billing/topup/checkout';
24
+ /** The one coded error body this command acts on; every other code stays a `BillingFailure`. */
25
+ const NO_PAYMENT_METHOD_SERVER_CODE = 'NO_PAYMENT_METHOD';
26
+ const INVALID_AMOUNT = {
27
+ status: 'error',
28
+ code: 'INVALID_AMOUNT'
29
+ };
30
+ const INVALID_RETURN_URL = {
31
+ status: 'error',
32
+ code: 'INVALID_RETURN_URL'
33
+ };
34
+ const NOT_AVAILABLE = {
35
+ status: 'error',
36
+ code: 'NOT_AVAILABLE'
37
+ };
38
+ function validateHostedCheckoutInput(input) {
39
+ const parsed = zCreateTopupCheckoutRequest.safeParse({
40
+ amount_cents: input.amountCents,
41
+ return_url: input.returnUrl
42
+ });
43
+ if (parsed.success)
44
+ return undefined;
45
+ const fields = new Set(parsed.error.issues.map((issue) => issue.path[0]));
46
+ return fields.has('amount_cents') ? INVALID_AMOUNT : INVALID_RETURN_URL;
47
+ }
48
+ const SUPERSEDED = {
49
+ status: 'error',
50
+ code: 'SUPERSEDED'
51
+ };
52
+ function commandFailure(failure) {
53
+ if (matchesServerCode(failure, NO_PAYMENT_METHOD_SERVER_CODE)) {
54
+ return {
55
+ status: 'error',
56
+ code: 'NO_PAYMENT_METHOD',
57
+ recoveryAction: 'replace_payment_method'
58
+ };
59
+ }
60
+ if (failure.httpStatus === 404)
61
+ return NOT_AVAILABLE;
62
+ return failure;
63
+ }
64
+ export function createTopupCommand(options) {
65
+ const { transport, lifecycle, capabilities, credits, idempotencyKey = () => crypto.randomUUID() } = options;
66
+ async function issue(amountCents, signal) {
67
+ // The schema coerces `amount_cents` to a bigint, which the transport
68
+ // could not serialize; the validated input is what travels, not the
69
+ // parsed output.
70
+ const body = {
71
+ amount_cents: amountCents,
72
+ idempotency_key: idempotencyKey()
73
+ };
74
+ const response = await readValidatedBillingResponse(transport, {
75
+ method: 'POST',
76
+ route: TOPUP_ROUTE,
77
+ body,
78
+ idempotencyKey: body.idempotency_key,
79
+ ...(signal === undefined ? {} : { signal })
80
+ }, (raw) => zCreateTopupResponse.safeParse(raw));
81
+ if (response.status === 'error')
82
+ return response;
83
+ // Adopted regardless of the response's own `status`: the first poll
84
+ // settles a synchronous result and carries the decline reason, which the
85
+ // POST response does not.
86
+ return {
87
+ status: 'ok',
88
+ value: { operationId: response.value.data.billing_op_id }
89
+ };
90
+ }
91
+ async function conclude(operation, baselineMicros) {
92
+ if (!isTerminal(operation))
93
+ return SUPERSEDED;
94
+ switch (operation.phase) {
95
+ case 'succeeded': {
96
+ capabilities.invalidate();
97
+ const after = await credits.read();
98
+ return {
99
+ status: 'ok',
100
+ operation,
101
+ creditsReconciled: baselineMicros !== undefined &&
102
+ after.status === 'ok' &&
103
+ after.value.balance.amount_micros > baselineMicros
104
+ };
105
+ }
106
+ case 'failed':
107
+ return { status: 'declined', operation };
108
+ case 'timed_out':
109
+ case 'reconciliation_needed':
110
+ return { status: 'unsettled', operation };
111
+ case 'superseded':
112
+ return SUPERSEDED;
113
+ }
114
+ }
115
+ async function readTopupEligibility(signal) {
116
+ const allowed = await capabilities.read(signal === undefined ? {} : { signal });
117
+ if (allowed.status === 'error')
118
+ return allowed;
119
+ if (allowed.value.capabilities.can_top_up)
120
+ return undefined;
121
+ const denial = allowed.value.denials.can_top_up;
122
+ return {
123
+ status: 'error',
124
+ code: 'ACCESS_DENIED',
125
+ ...(denial === undefined ? {} : { denial })
126
+ };
127
+ }
128
+ async function createTopupCheckout(input) {
129
+ const { amountCents, signal } = input;
130
+ if (!zCreateTopupRequest.safeParse({ amount_cents: amountCents }).success) {
131
+ return INVALID_AMOUNT;
132
+ }
133
+ const refused = await readTopupEligibility(signal);
134
+ if (refused)
135
+ return refused;
136
+ const baseline = await credits.read();
137
+ const baselineMicros = baseline.status === 'ok'
138
+ ? baseline.value.balance.amount_micros
139
+ : undefined;
140
+ // `begin` reports every failure as a `BillingFailure`; the command-level
141
+ // meaning of its own POST failure is kept here so a status-read failure
142
+ // is never mistaken for one.
143
+ let issueFailure;
144
+ const began = await lifecycle.begin('topup', async () => {
145
+ const issued = await issue(amountCents, signal);
146
+ if (issued.status === 'error')
147
+ issueFailure = commandFailure(issued);
148
+ return issued;
149
+ });
150
+ if (began.status === 'error')
151
+ return issueFailure ?? began;
152
+ const settled = lifecycle.settled(began.value.id);
153
+ if (settled === undefined)
154
+ return SUPERSEDED;
155
+ return conclude(await settled, baselineMicros);
156
+ }
157
+ async function issueHostedCheckout(input) {
158
+ const body = {
159
+ amount_cents: input.amountCents,
160
+ return_url: input.returnUrl,
161
+ idempotency_key: idempotencyKey()
162
+ };
163
+ const response = await readValidatedBillingResponse(transport, {
164
+ method: 'POST',
165
+ route: TOPUP_CHECKOUT_ROUTE,
166
+ body,
167
+ idempotencyKey: body.idempotency_key,
168
+ ...(input.signal === undefined ? {} : { signal: input.signal })
169
+ }, (raw) => zCreateTopupCheckoutResponse.safeParse(raw));
170
+ if (response.status === 'error')
171
+ return response;
172
+ // The generated contract only requires a URL; the page a host will open
173
+ // on the customer's behalf has to be https as well, like every
174
+ // continuation the lifecycle hands out.
175
+ const { data, httpStatus } = response.value;
176
+ const url = validateActionUrl(data.checkout_url);
177
+ if (url === undefined) {
178
+ return { status: 'error', code: 'MALFORMED_RESPONSE', httpStatus };
179
+ }
180
+ return {
181
+ status: 'ok',
182
+ value: {
183
+ url,
184
+ ...(data.session_id === undefined ? {} : { sessionId: data.session_id })
185
+ }
186
+ };
187
+ }
188
+ async function createHostedTopupCheckout(input) {
189
+ const invalid = validateHostedCheckoutInput(input);
190
+ if (invalid)
191
+ return invalid;
192
+ const refused = await readTopupEligibility(input.signal);
193
+ if (refused)
194
+ return refused;
195
+ const baseline = await credits.read();
196
+ const session = await issueHostedCheckout(input);
197
+ if (session.status === 'error') {
198
+ return session.httpStatus === 404 ? NOT_AVAILABLE : session;
199
+ }
200
+ return {
201
+ status: 'ok',
202
+ ...session.value,
203
+ ...(baseline.status === 'ok'
204
+ ? { baselineMicros: baseline.value.balance.amount_micros }
205
+ : {})
206
+ };
207
+ }
208
+ return { createTopupCheckout, createHostedTopupCheckout };
209
+ }
@@ -0,0 +1,14 @@
1
+ import type { BillingSession, BillingTransport } from './billingContracts.js';
2
+ export interface SessionBillingTransportOptions {
3
+ readonly session: BillingSession;
4
+ /**
5
+ * Resolves a route to an absolute URL. The cloud app's own resolver
6
+ * (`workspaceApiUrl`) differs per distribution, so the host owns it.
7
+ */
8
+ readonly resolveUrl: (route: string) => string;
9
+ /** The workspace to mint for; absent resolves the personal workspace. */
10
+ readonly workspaceId?: () => string | undefined;
11
+ readonly fetchImpl?: typeof fetch;
12
+ readonly defaultTimeoutMs?: number;
13
+ }
14
+ export declare function createSessionBillingTransport(options: SessionBillingTransportOptions): BillingTransport;
@@ -0,0 +1,106 @@
1
+ import { DEFAULT_BILLING_TIMEOUT_MS, exchangeBillingRequest, startRequestBudget } from './transportExchange.js';
2
+ function codeForSessionFailure(code) {
3
+ if (code === 'ACCESS_DENIED')
4
+ return 'ACCESS_DENIED';
5
+ if (code === 'WORKSPACE_NOT_FOUND')
6
+ return 'NOT_FOUND';
7
+ if (code === 'NOT_AUTHENTICATED' || code === 'INVALID_FIREBASE_TOKEN') {
8
+ return 'NOT_AUTHENTICATED';
9
+ }
10
+ return 'REQUEST_FAILED';
11
+ }
12
+ function billingFailureForSession(failure) {
13
+ return {
14
+ status: 'error',
15
+ code: codeForSessionFailure(failure.code),
16
+ ...(failure.httpStatus === undefined
17
+ ? {}
18
+ : { httpStatus: failure.httpStatus })
19
+ };
20
+ }
21
+ export function createSessionBillingTransport(options) {
22
+ const { session, resolveUrl, workspaceId, fetchImpl = fetch, defaultTimeoutMs = DEFAULT_BILLING_TIMEOUT_MS } = options;
23
+ /**
24
+ * The identity and workspace a response is allowed to be attributed to.
25
+ * A snapshot still minting or not yet settled is no evidence of a change,
26
+ * so only a delivered sign-out or a different uid or workspace supersedes.
27
+ */
28
+ const superseded = (uid, workspace) => {
29
+ const snapshot = session.getSnapshot();
30
+ if (snapshot.phase === 'signed-out')
31
+ return true;
32
+ if (snapshot.user !== null && snapshot.user.uid !== uid)
33
+ return true;
34
+ return (snapshot.session !== undefined &&
35
+ snapshot.session.workspace.id !== workspace);
36
+ };
37
+ async function ensureBillingSession(mintOptions) {
38
+ const minted = await session.ensureFresh(undefined, mintOptions);
39
+ if (minted === undefined) {
40
+ return { status: 'error', code: 'NOT_AUTHENTICATED' };
41
+ }
42
+ if (minted.status === 'error')
43
+ return billingFailureForSession(minted);
44
+ return { status: 'ok', value: minted.session };
45
+ }
46
+ function send(request, token, signal) {
47
+ return exchangeBillingRequest(request, {
48
+ fetchImpl,
49
+ url: resolveUrl(request.route),
50
+ signal,
51
+ headers: { Authorization: `Bearer ${token}` }
52
+ });
53
+ }
54
+ async function retryUnauthorized(request, response, mintOptions, expectedUid, expectedWorkspace) {
55
+ if (response.httpStatus !== 401)
56
+ return { status: 'ok', value: response };
57
+ const replayable = request.method === 'GET' || request.idempotencyKey !== undefined;
58
+ if (!replayable) {
59
+ return {
60
+ status: 'ok',
61
+ value: { ...response, authenticationRetrySkipped: true }
62
+ };
63
+ }
64
+ const reminted = await session.remint(undefined, mintOptions);
65
+ if (reminted?.status !== 'ok')
66
+ return { status: 'ok', value: response };
67
+ if (reminted.session.uid !== expectedUid ||
68
+ reminted.session.workspace.id !== expectedWorkspace) {
69
+ return { status: 'error', code: 'SUPERSEDED' };
70
+ }
71
+ return send(request, reminted.session.token, mintOptions.signal);
72
+ }
73
+ return async function transport(request) {
74
+ const timeoutMs = request.timeoutMs ?? defaultTimeoutMs;
75
+ const budget = startRequestBudget(request.signal, timeoutMs);
76
+ const target = workspaceId?.();
77
+ const mintOptions = {
78
+ signal: budget.signal,
79
+ timeoutMs,
80
+ ...(target === undefined ? {} : { workspaceId: target })
81
+ };
82
+ try {
83
+ const minted = await ensureBillingSession(mintOptions);
84
+ if (minted.status === 'error')
85
+ return minted;
86
+ const { uid } = minted.value;
87
+ const workspace = minted.value.workspace.id;
88
+ const firstAttempt = await send(request, minted.value.token, budget.signal);
89
+ if (firstAttempt.status === 'error')
90
+ return firstAttempt;
91
+ const finalAttempt = await retryUnauthorized(request, firstAttempt.value, mintOptions, uid, workspace);
92
+ if (finalAttempt.status === 'error')
93
+ return finalAttempt;
94
+ if (superseded(uid, workspace)) {
95
+ return { status: 'error', code: 'SUPERSEDED' };
96
+ }
97
+ return finalAttempt;
98
+ }
99
+ catch {
100
+ return { status: 'error', code: 'REQUEST_FAILED' };
101
+ }
102
+ finally {
103
+ budget.close();
104
+ }
105
+ };
106
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What every billing transport does identically, regardless of how the
3
+ * request is credentialed: hold one timeout budget over the whole exchange,
4
+ * send the request, and shape any answer into a `BillingHttpResponse`.
5
+ *
6
+ * Only the credential differs between transports, so only the credential
7
+ * lives in them; keeping the budget and the response shaping here is what
8
+ * lets a bearer-token host and a cookie host report the same failures.
9
+ */
10
+ import type { BillingHttpResponse, BillingRequest, BillingResult } from './billingContracts.js';
11
+ export declare const DEFAULT_BILLING_TIMEOUT_MS = 30000;
12
+ export declare function startRequestBudget(callerSignal: AbortSignal | undefined, timeoutMs: number): {
13
+ signal: AbortSignal;
14
+ close: () => void;
15
+ };
16
+ export interface BillingExchange {
17
+ readonly fetchImpl: typeof fetch;
18
+ readonly url: string;
19
+ readonly signal: AbortSignal;
20
+ /** Merged over the headers the request itself implies. */
21
+ readonly headers?: Record<string, string>;
22
+ readonly credentials?: RequestCredentials;
23
+ }
24
+ export declare function exchangeBillingRequest(request: BillingRequest, exchange: BillingExchange): Promise<BillingResult<BillingHttpResponse>>;
@@ -0,0 +1,79 @@
1
+ export const DEFAULT_BILLING_TIMEOUT_MS = 30_000;
2
+ export function startRequestBudget(callerSignal, timeoutMs) {
3
+ const controller = new AbortController();
4
+ const abort = () => controller.abort();
5
+ if (callerSignal?.aborted === true) {
6
+ controller.abort();
7
+ }
8
+ else {
9
+ callerSignal?.addEventListener('abort', abort, { once: true });
10
+ }
11
+ const timeout = setTimeout(abort, timeoutMs);
12
+ return {
13
+ signal: controller.signal,
14
+ close: () => {
15
+ clearTimeout(timeout);
16
+ callerSignal?.removeEventListener('abort', abort);
17
+ }
18
+ };
19
+ }
20
+ export async function exchangeBillingRequest(request, exchange) {
21
+ // Called detached: the browser's fetch refuses any receiver but its global.
22
+ const { fetchImpl, url, signal, headers, credentials } = exchange;
23
+ let response;
24
+ try {
25
+ response = await fetchImpl(url, {
26
+ method: request.method,
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ ...(request.idempotencyKey === undefined
30
+ ? {}
31
+ : { 'Idempotency-Key': request.idempotencyKey }),
32
+ ...headers
33
+ },
34
+ ...(request.method === 'GET' || request.body === undefined
35
+ ? {}
36
+ : { body: JSON.stringify(request.body) }),
37
+ ...(credentials === undefined ? {} : { credentials }),
38
+ signal
39
+ });
40
+ }
41
+ catch {
42
+ return { status: 'error', code: 'REQUEST_FAILED' };
43
+ }
44
+ return shapeResponse(response, signal);
45
+ }
46
+ async function shapeResponse(response, signal) {
47
+ const header = (name) => response.headers.get(name);
48
+ try {
49
+ return {
50
+ status: 'ok',
51
+ value: {
52
+ httpStatus: response.status,
53
+ body: await readBody(response),
54
+ header
55
+ }
56
+ };
57
+ }
58
+ catch {
59
+ if (signal.aborted) {
60
+ return { status: 'error', code: 'REQUEST_FAILED' };
61
+ }
62
+ return {
63
+ status: 'ok',
64
+ value: { httpStatus: response.status, body: undefined, header }
65
+ };
66
+ }
67
+ }
68
+ /** A body that is absent, empty, or not JSON reaches callers as undefined. */
69
+ async function readBody(response) {
70
+ const text = await response.text();
71
+ if (text === '')
72
+ return undefined;
73
+ try {
74
+ return JSON.parse(text);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A monotonic owner for work that outlives an await: each `capture()` snapshots
3
+ * the current generation and returns a handle whose `live()` stays true until
4
+ * `abandon()` bumps the generation. A flag flip, a reset, or teardown calls
5
+ * `abandon()`; the in-flight work re-checks `live()` after every await and drops
6
+ * its result once superseded, so a stale sign-in, mint, or fetch can never
7
+ * publish for an attempt that no longer owns the outcome.
8
+ */
9
+ export interface OperationHandle {
10
+ live: () => boolean;
11
+ }
12
+ export interface BoundedOperation {
13
+ capture: () => OperationHandle;
14
+ abandon: () => void;
15
+ }
16
+ export declare function createBoundedOperation(): BoundedOperation;
@@ -0,0 +1,12 @@
1
+ export function createBoundedOperation() {
2
+ let generation = 0;
3
+ return {
4
+ capture() {
5
+ const captured = generation;
6
+ return { live: () => captured === generation };
7
+ },
8
+ abandon() {
9
+ generation += 1;
10
+ }
11
+ };
12
+ }
@@ -0,0 +1,41 @@
1
+ import type { AccountCredential } from './sessionContracts.js';
2
+ /**
3
+ * Raw string storage for the credential cache. Hosts wrap their medium —
4
+ * per-tab browser storage today, a cookie-backed session tomorrow. Each client
5
+ * instance sees only its own storage: signing out in one tab leaves another
6
+ * tab's session live until the server revokes it and the next remint 401s.
7
+ */
8
+ export interface CredentialStorage {
9
+ read: () => string | null;
10
+ write: (value: string) => void;
11
+ clear: () => void;
12
+ }
13
+ interface CachedCredential {
14
+ readonly credential: AccountCredential;
15
+ readonly target: string | undefined;
16
+ }
17
+ export declare function encodeCached(session: AccountCredential, target: string | undefined): string;
18
+ export declare function decodeCached(raw: string, uid: string): CachedCredential | undefined;
19
+ export declare function decodeAdopted(message: unknown): AccountCredential | undefined;
20
+ interface CredentialCache {
21
+ read: (uid: string) => CachedCredential | undefined;
22
+ write: (session: AccountCredential, target: string | undefined) => void;
23
+ clear: () => void;
24
+ }
25
+ export declare const DEFAULT_FRESH_MARGIN_MS: number;
26
+ export declare function isCredentialFresh(session: AccountCredential, now: number, freshMarginMs?: number): boolean;
27
+ interface CredentialCandidate {
28
+ readonly credential: AccountCredential | undefined;
29
+ readonly target: string | undefined;
30
+ }
31
+ /**
32
+ * The live credential is authoritative; storage is recovery state, not a
33
+ * competing source. Candidates are consulted in the order given (memory
34
+ * first, then storage) for this exact uid and target, and the first fresh one
35
+ * wins — expiry must not override this (a rejected token can outlive its
36
+ * shorter-lived replacement), and a target-less read must never adopt a team
37
+ * session.
38
+ */
39
+ export declare function selectFreshCredential(candidates: readonly (() => CredentialCandidate | undefined)[], uid: string, target: string | undefined, now: number, freshMarginMs: number): AccountCredential | undefined;
40
+ export declare function createCredentialCache(storage: CredentialStorage): CredentialCache;
41
+ export {};