@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.2
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/billingContracts.d.ts +29 -14
- package/dist/core/billing/billingErrorBody.d.ts +8 -4
- package/dist/core/billing/billingErrorBody.js +14 -4
- package/dist/core/billing/credentialedTransport.d.ts +23 -7
- package/dist/core/billing/credentialedTransport.js +72 -8
- package/dist/core/billing/events.d.ts +47 -0
- package/dist/core/billing/events.js +43 -0
- package/dist/core/billing/index.d.ts +16 -11
- package/dist/core/billing/index.js +8 -6
- package/dist/core/billing/operationLifecycle.d.ts +34 -1
- package/dist/core/billing/operationLifecycle.js +146 -67
- package/dist/core/billing/operationPointer.d.ts +27 -2
- package/dist/core/billing/operationPointer.js +36 -25
- package/dist/core/billing/operationPolicy.d.ts +44 -7
- package/dist/core/billing/operationPolicy.js +64 -3
- package/dist/core/billing/operationState.d.ts +225 -19
- package/dist/core/billing/operationState.js +93 -8
- package/dist/core/billing/paymentCopy.d.ts +6 -1
- package/dist/core/billing/paymentCopy.js +34 -1
- package/dist/core/billing/paymentProjection.d.ts +14 -2
- package/dist/core/billing/paymentProjection.js +37 -9
- package/dist/core/billing/scopedReader.d.ts +11 -5
- package/dist/core/billing/scopedReader.js +20 -9
- package/dist/core/billing/sharedRead.js +4 -2
- package/dist/core/billing/subscriptionCommands.d.ts +63 -9
- package/dist/core/billing/subscriptionCommands.js +46 -30
- package/dist/core/billing/topup.d.ts +26 -0
- package/dist/core/billing/topup.js +36 -2
- package/dist/core/billing/wireCents.d.ts +8 -0
- package/dist/core/billing/wireCents.js +8 -0
- package/dist/core/billing/workspaceInvites.d.ts +12 -0
- package/dist/core/billing/workspaceInvites.js +26 -0
- package/dist/core/identity.d.ts +6 -6
- package/dist/core/identity.js +4 -10
- package/dist/core/lazyIdentity.d.ts +31 -0
- package/dist/core/lazyIdentity.js +96 -0
- package/dist/core/requestAuth.d.ts +30 -0
- package/dist/core/requestAuth.js +41 -0
- package/dist/core/requestTimeout.d.ts +7 -0
- package/dist/core/requestTimeout.js +13 -0
- package/dist/core/session.d.ts +12 -18
- package/dist/core/session.js +16 -26
- package/dist/core/sessionContracts.d.ts +39 -2
- package/dist/core/sessionTokenMint.d.ts +35 -0
- package/dist/core/sessionTokenMint.js +222 -0
- package/dist/core/webSession.d.ts +27 -0
- package/dist/core/webSession.js +144 -0
- package/dist/core/webSessionFlag.d.ts +19 -0
- package/dist/core/webSessionFlag.js +61 -0
- package/dist/core/webSessionIdentity.d.ts +297 -0
- package/dist/core/webSessionIdentity.js +509 -0
- package/dist/firebase/configSource.d.ts +54 -0
- package/dist/firebase/configSource.js +111 -0
- package/dist/firebase/index.d.ts +71 -8
- package/dist/firebase/index.js +236 -23
- package/dist/firebase/popupWatch.d.ts +42 -0
- package/dist/firebase/popupWatch.js +140 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +116 -0
- package/dist/web/crossTabRefresh.d.ts +4 -2
- package/dist/web/crossTabRefresh.js +13 -0
- package/dist/workspaceLink.d.ts +45 -0
- package/dist/workspaceLink.js +78 -0
- package/package.json +30 -6
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* own vocabulary, and the terminal operation is embedded whenever one was
|
|
11
11
|
* observed so a host reads the coded decline and recovery action from it.
|
|
12
12
|
*/
|
|
13
|
-
import { zCreateTopupCheckoutRequest, zCreateTopupCheckoutResponse, zCreateTopupRequest, zCreateTopupResponse } from '@comfyorg/ingest-types/zod';
|
|
13
|
+
import { zCreateTopupCheckoutRequest, zCreateTopupCheckoutResponse, zCreateTopupRequest, zCreateTopupResponse, zTopupQuoteResponse } from '@comfyorg/ingest-types/zod';
|
|
14
14
|
import { matchesServerCode } from './billingContracts.js';
|
|
15
15
|
import { isTerminal, validateActionUrl } from './operationState.js';
|
|
16
16
|
import { readValidatedBillingResponse } from './sharedRead.js';
|
|
17
|
+
import { wireCents } from './wireCents.js';
|
|
17
18
|
export const TOPUP_ROUTE = '/billing/topup';
|
|
18
19
|
/**
|
|
19
20
|
* The hosted variant: a provider-hosted Checkout session with no operation to
|
|
@@ -21,6 +22,14 @@ export const TOPUP_ROUTE = '/billing/topup';
|
|
|
21
22
|
* through a webhook, so completion is the host's return plus a balance watch.
|
|
22
23
|
*/
|
|
23
24
|
export const TOPUP_CHECKOUT_ROUTE = '/billing/topup/checkout';
|
|
25
|
+
/** A read the backend shapes as a POST: nothing is charged or reserved. */
|
|
26
|
+
export const TOPUP_QUOTE_ROUTE = '/billing/topup/quote';
|
|
27
|
+
/** The server's own server code for an amount it will not sell. */
|
|
28
|
+
const INVALID_AMOUNT_SERVER_CODE = 'INVALID_AMOUNT';
|
|
29
|
+
const TopupQuoteSchema = zTopupQuoteResponse.extend({
|
|
30
|
+
amount_cents: wireCents,
|
|
31
|
+
credits: wireCents
|
|
32
|
+
});
|
|
24
33
|
/** The one coded error body this command acts on; every other code stays a `BillingFailure`. */
|
|
25
34
|
const NO_PAYMENT_METHOD_SERVER_CODE = 'NO_PAYMENT_METHOD';
|
|
26
35
|
const INVALID_AMOUNT = {
|
|
@@ -205,5 +214,30 @@ export function createTopupCommand(options) {
|
|
|
205
214
|
: {})
|
|
206
215
|
};
|
|
207
216
|
}
|
|
208
|
-
|
|
217
|
+
async function quoteTopup(input) {
|
|
218
|
+
const { amountCents, signal } = input;
|
|
219
|
+
if (!wireCents.positive().safeParse(amountCents).success)
|
|
220
|
+
return INVALID_AMOUNT;
|
|
221
|
+
const response = await readValidatedBillingResponse(transport, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
route: TOPUP_QUOTE_ROUTE,
|
|
224
|
+
body: { amount_cents: amountCents },
|
|
225
|
+
...(signal === undefined ? {} : { signal })
|
|
226
|
+
}, (raw) => TopupQuoteSchema.safeParse(raw));
|
|
227
|
+
if (response.status === 'error') {
|
|
228
|
+
if (matchesServerCode(response, INVALID_AMOUNT_SERVER_CODE))
|
|
229
|
+
return INVALID_AMOUNT;
|
|
230
|
+
return response.httpStatus === 404 ? NOT_AVAILABLE : response;
|
|
231
|
+
}
|
|
232
|
+
const quote = response.value.data;
|
|
233
|
+
return {
|
|
234
|
+
status: 'ok',
|
|
235
|
+
value: {
|
|
236
|
+
amountCents: quote.amount_cents,
|
|
237
|
+
credits: quote.credits,
|
|
238
|
+
expiresAt: quote.expires_at
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { quoteTopup, createTopupCheckout, createHostedTopupCheckout };
|
|
209
243
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The generated schemas coerce every int64 to a `bigint`, which no caller can
|
|
4
|
+
* add to a price or hand to a currency formatter, while the generated *type*
|
|
5
|
+
* for the same field is a `number`. Billing amounts and credit counts sit well
|
|
6
|
+
* inside the JavaScript-safe range, so they are read as safe integers.
|
|
7
|
+
*/
|
|
8
|
+
export declare const wireCents: z.ZodNumber;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The generated schemas coerce every int64 to a `bigint`, which no caller can
|
|
4
|
+
* add to a price or hand to a currency formatter, while the generated *type*
|
|
5
|
+
* for the same field is a `number`. Billing amounts and credit counts sit well
|
|
6
|
+
* inside the JavaScript-safe range, so they are read as safe integers.
|
|
7
|
+
*/
|
|
8
|
+
export const wireCents = z.number().int().safe();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { zPendingInvite } from '@comfyorg/ingest-types/zod';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { BillingResult, BillingTransport } from './billingContracts.js';
|
|
4
|
+
export declare const WORKSPACE_INVITES_ROUTE = "/workspace/invites";
|
|
5
|
+
export type WorkspaceInvite = z.infer<typeof zPendingInvite>;
|
|
6
|
+
export interface WorkspaceInviteCommands {
|
|
7
|
+
readonly listPendingInvites: () => Promise<BillingResult<readonly WorkspaceInvite[]>>;
|
|
8
|
+
readonly createInvite: (email: string) => Promise<BillingResult<WorkspaceInvite>>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createWorkspaceInviteCommands(options: {
|
|
11
|
+
readonly transport: BillingTransport;
|
|
12
|
+
}): WorkspaceInviteCommands;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace's pending invites, over the same session transport the
|
|
3
|
+
* billing commands use, so a host that checks out a team plan can invite the
|
|
4
|
+
* team without a second client. The workspace is the one the transport's
|
|
5
|
+
* token was minted for.
|
|
6
|
+
*/
|
|
7
|
+
import { zCreateWorkspaceInviteResponse, zListInvitesResponse } from '@comfyorg/ingest-types/zod';
|
|
8
|
+
import { readValidatedBillingResponse } from './sharedRead.js';
|
|
9
|
+
export const WORKSPACE_INVITES_ROUTE = '/workspace/invites';
|
|
10
|
+
export function createWorkspaceInviteCommands(options) {
|
|
11
|
+
const { transport } = options;
|
|
12
|
+
async function listPendingInvites() {
|
|
13
|
+
const response = await readValidatedBillingResponse(transport, { method: 'GET', route: WORKSPACE_INVITES_ROUTE }, (body) => zListInvitesResponse.safeParse(body));
|
|
14
|
+
return response.status === 'error'
|
|
15
|
+
? response
|
|
16
|
+
: { status: 'ok', value: response.value.data.invites };
|
|
17
|
+
}
|
|
18
|
+
async function createInvite(email) {
|
|
19
|
+
const body = { email };
|
|
20
|
+
const response = await readValidatedBillingResponse(transport, { method: 'POST', route: WORKSPACE_INVITES_ROUTE, body }, (raw) => zCreateWorkspaceInviteResponse.safeParse(raw));
|
|
21
|
+
return response.status === 'error'
|
|
22
|
+
? response
|
|
23
|
+
: { status: 'ok', value: response.value.data };
|
|
24
|
+
}
|
|
25
|
+
return { listPendingInvites, createInvite };
|
|
26
|
+
}
|
package/dist/core/identity.d.ts
CHANGED
|
@@ -2,20 +2,20 @@ import type { AccountUser } from './sessionContracts.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* The identity boundary. An internal port, not a host adapter: real hosts
|
|
4
4
|
* get their implementation from `@comfyorg/account-core/firebase`; tests brand a
|
|
5
|
-
* fake through `@comfyorg/account-core/testing`. `
|
|
6
|
-
* the branded form.
|
|
5
|
+
* fake through `@comfyorg/account-core/testing`. `createSessionClient` accepts
|
|
6
|
+
* only the branded form.
|
|
7
7
|
*/
|
|
8
8
|
export interface IdentityPort<TUser extends AccountUser = AccountUser> {
|
|
9
9
|
onUserChanged: (callback: (user: TUser | null) => void) => () => void;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The brand is a compile-time gate only: `createSessionClient` rejects an
|
|
13
|
+
* unbranded port in the type system, and no runtime check remains. The
|
|
14
|
+
* package's own identity entries mint it, and `./testing` deliberately
|
|
15
|
+
* re-exports the minter as `createTestIdentity` so a suite can brand a fake.
|
|
15
16
|
*/
|
|
16
17
|
export declare const identityBrand: unique symbol;
|
|
17
18
|
export interface AccountIdentity<TUser extends AccountUser = AccountUser> extends IdentityPort<TUser> {
|
|
18
19
|
readonly [identityBrand]: true;
|
|
19
20
|
}
|
|
20
21
|
export declare function brandIdentity<TUser extends AccountUser>(port: IdentityPort<TUser>): AccountIdentity<TUser>;
|
|
21
|
-
export declare function isAccountIdentity(value: unknown): value is AccountIdentity;
|
package/dist/core/identity.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The brand is a compile-time gate only: `createSessionClient` rejects an
|
|
3
|
+
* unbranded port in the type system, and no runtime check remains. The
|
|
4
|
+
* package's own identity entries mint it, and `./testing` deliberately
|
|
5
|
+
* re-exports the minter as `createTestIdentity` so a suite can brand a fake.
|
|
5
6
|
*/
|
|
6
7
|
export const identityBrand = Symbol('@comfyorg/account-core identity');
|
|
7
8
|
export function brandIdentity(port) {
|
|
8
9
|
return { ...port, [identityBrand]: true };
|
|
9
10
|
}
|
|
10
|
-
export function isAccountIdentity(value) {
|
|
11
|
-
if (typeof value !== 'object' || value === null)
|
|
12
|
-
return false;
|
|
13
|
-
const candidate = value;
|
|
14
|
-
return (candidate[identityBrand] === true &&
|
|
15
|
-
typeof candidate.onUserChanged === 'function');
|
|
16
|
-
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An identity port whose real identity is loaded and subscribed only on
|
|
3
|
+
* `activate()`, so a host can construct the session client at module load
|
|
4
|
+
* while the identity's module (and the SDK behind it) stays a lazy chunk.
|
|
5
|
+
*/
|
|
6
|
+
import type { AccountIdentity } from './identity.js';
|
|
7
|
+
import type { AccountUser } from './sessionContracts.js';
|
|
8
|
+
/**
|
|
9
|
+
* Single-owner: one driver calls `activate()` and `deactivate()`; a second
|
|
10
|
+
* consumer would share and tear down the same activation.
|
|
11
|
+
*/
|
|
12
|
+
export interface LazyIdentity<TUser extends AccountUser> extends AccountIdentity<TUser> {
|
|
13
|
+
/**
|
|
14
|
+
* Resolves once the loaded identity has delivered its current user, or at
|
|
15
|
+
* once when `deactivate()` interrupts the activation; the caller re-checks
|
|
16
|
+
* its own liveness after the await.
|
|
17
|
+
*/
|
|
18
|
+
activate: () => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Unsubscribes the real identity, signing subscribers out if it had
|
|
21
|
+
* delivered a signed-in user.
|
|
22
|
+
*/
|
|
23
|
+
deactivate: () => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An identity that has already settled signed-out: for a `load` that
|
|
27
|
+
* legitimately has no source to resolve, so the port still delivers once and
|
|
28
|
+
* a subscriber reaches signed-out instead of hanging in `pending` forever.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createUnavailableIdentity<TUser extends AccountUser>(): AccountIdentity<TUser>;
|
|
31
|
+
export declare function createLazyIdentity<TUser extends AccountUser>(load: () => Promise<AccountIdentity<TUser>>): LazyIdentity<TUser>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { brandIdentity } from './identity.js';
|
|
2
|
+
/**
|
|
3
|
+
* An identity that has already settled signed-out: for a `load` that
|
|
4
|
+
* legitimately has no source to resolve, so the port still delivers once and
|
|
5
|
+
* a subscriber reaches signed-out instead of hanging in `pending` forever.
|
|
6
|
+
*/
|
|
7
|
+
export function createUnavailableIdentity() {
|
|
8
|
+
return brandIdentity({
|
|
9
|
+
onUserChanged: (callback) => {
|
|
10
|
+
callback(null);
|
|
11
|
+
return () => undefined;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function createLazyIdentity(load) {
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
let generation = 0;
|
|
18
|
+
let activation;
|
|
19
|
+
let settleActivation;
|
|
20
|
+
let unsubscribe;
|
|
21
|
+
let lastDelivered;
|
|
22
|
+
function deliver(user) {
|
|
23
|
+
lastDelivered = { user };
|
|
24
|
+
listeners.forEach((listener) => listener(user));
|
|
25
|
+
}
|
|
26
|
+
async function subscribe(started, resolve) {
|
|
27
|
+
const identity = await load();
|
|
28
|
+
if (started !== generation)
|
|
29
|
+
return;
|
|
30
|
+
unsubscribe = identity.onUserChanged((user) => {
|
|
31
|
+
if (started !== generation)
|
|
32
|
+
return;
|
|
33
|
+
resolve();
|
|
34
|
+
deliver(user);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function activate() {
|
|
38
|
+
if (activation)
|
|
39
|
+
return activation;
|
|
40
|
+
const started = ++generation;
|
|
41
|
+
activation = new Promise((resolve, reject) => {
|
|
42
|
+
settleActivation = resolve;
|
|
43
|
+
subscribe(started, resolve).catch((error) => {
|
|
44
|
+
if (started !== generation)
|
|
45
|
+
return;
|
|
46
|
+
activation = undefined;
|
|
47
|
+
settleActivation = undefined;
|
|
48
|
+
// A load that can't produce an identity still owes subscribers an
|
|
49
|
+
// answer: deliver signed-out so nobody is left waiting on a promise
|
|
50
|
+
// this activation will never settle. A throwing listener must not
|
|
51
|
+
// swallow the loader's own error, so reject unconditionally; this
|
|
52
|
+
// runs inside an unawaited .catch(), so a listener's own exception
|
|
53
|
+
// is reported rather than left to become an unhandled rejection.
|
|
54
|
+
try {
|
|
55
|
+
deliver(null);
|
|
56
|
+
}
|
|
57
|
+
catch (deliverError) {
|
|
58
|
+
console.warn('lazyIdentity: a listener threw delivering signed-out after a load failure', deliverError);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
reject(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return activation;
|
|
66
|
+
}
|
|
67
|
+
function deactivate() {
|
|
68
|
+
if (!activation)
|
|
69
|
+
return;
|
|
70
|
+
generation += 1;
|
|
71
|
+
activation = undefined;
|
|
72
|
+
settleActivation?.();
|
|
73
|
+
settleActivation = undefined;
|
|
74
|
+
unsubscribe?.();
|
|
75
|
+
unsubscribe = undefined;
|
|
76
|
+
const wasSignedIn = lastDelivered !== undefined && lastDelivered.user !== null;
|
|
77
|
+
lastDelivered = undefined;
|
|
78
|
+
if (!wasSignedIn)
|
|
79
|
+
return;
|
|
80
|
+
listeners.forEach((listener) => listener(null));
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...brandIdentity({
|
|
84
|
+
onUserChanged: (listener) => {
|
|
85
|
+
listeners.add(listener);
|
|
86
|
+
if (lastDelivered)
|
|
87
|
+
listener(lastDelivered.user);
|
|
88
|
+
return () => {
|
|
89
|
+
listeners.delete(listener);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
activate,
|
|
94
|
+
deactivate
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WebSession } from './sessionContracts.js';
|
|
2
|
+
export type RequestTarget = 'ingest' | 'resource';
|
|
3
|
+
export type RequestPrincipal = {
|
|
4
|
+
readonly kind: 'session';
|
|
5
|
+
readonly session: WebSession;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'firebase';
|
|
8
|
+
readonly getIdToken: () => Promise<string>;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: 'apiKey';
|
|
11
|
+
readonly apiKey: string;
|
|
12
|
+
};
|
|
13
|
+
export interface RequestAuthInput {
|
|
14
|
+
readonly target: RequestTarget;
|
|
15
|
+
readonly method: string;
|
|
16
|
+
/** Absent means the personal workspace. */
|
|
17
|
+
readonly workspaceId?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface RequestAuthorization {
|
|
20
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
21
|
+
/** Set only for a cookie request, which must carry the session cookie. */
|
|
22
|
+
readonly credentials?: 'include';
|
|
23
|
+
}
|
|
24
|
+
export type RequestAuthorizer = (principal: RequestPrincipal, input: RequestAuthInput) => Promise<RequestAuthorization>;
|
|
25
|
+
export interface RequestAuthOptions {
|
|
26
|
+
/** A workspace token for a session principal; minting is the caller's. */
|
|
27
|
+
readonly getWorkspaceToken: (workspaceId: string | undefined) => Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
export declare const COMFY_CLIENT: string;
|
|
30
|
+
export declare function createRequestAuthorizer({ getWorkspaceToken }: RequestAuthOptions): RequestAuthorizer;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides the credential headers for one request, and nothing else: callers
|
|
3
|
+
* send, retry, and track scope. A cookie session authorizes ingest itself;
|
|
4
|
+
* every other service takes a Bearer workspace token. Firebase and API-key
|
|
5
|
+
* principals keep the single header they send today, whatever the target.
|
|
6
|
+
*/
|
|
7
|
+
import packageManifest from '../../package.json' with { type: 'json' };
|
|
8
|
+
export const COMFY_CLIENT = `${packageManifest.name}/${packageManifest.version}`;
|
|
9
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
10
|
+
function sessionHeaders(session, { method, workspaceId }) {
|
|
11
|
+
return {
|
|
12
|
+
'X-Comfy-Client': COMFY_CLIENT,
|
|
13
|
+
...(workspaceId === undefined
|
|
14
|
+
? {}
|
|
15
|
+
: { 'X-Comfy-Workspace-ID': workspaceId }),
|
|
16
|
+
...(SAFE_METHODS.has(method.toUpperCase())
|
|
17
|
+
? {}
|
|
18
|
+
: { 'X-CSRF-Token': session.csrfToken })
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function createRequestAuthorizer({ getWorkspaceToken }) {
|
|
22
|
+
return async function authorize(principal, input) {
|
|
23
|
+
switch (principal.kind) {
|
|
24
|
+
case 'apiKey':
|
|
25
|
+
return { headers: { 'X-API-KEY': principal.apiKey } };
|
|
26
|
+
case 'firebase':
|
|
27
|
+
return {
|
|
28
|
+
headers: { Authorization: `Bearer ${await principal.getIdToken()}` }
|
|
29
|
+
};
|
|
30
|
+
case 'session':
|
|
31
|
+
if (input.target === 'resource') {
|
|
32
|
+
const token = await getWorkspaceToken(input.workspaceId);
|
|
33
|
+
return { headers: { Authorization: `Bearer ${token}` } };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
headers: sessionHeaders(principal.session, input),
|
|
37
|
+
credentials: 'include'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/core/session.d.ts
CHANGED
|
@@ -53,13 +53,16 @@ export interface SessionClientOptions extends SessionRequestOptions {
|
|
|
53
53
|
readonly storage: CredentialStorage;
|
|
54
54
|
readonly freshMarginMs?: number;
|
|
55
55
|
readonly refreshScheduler?: RefreshSchedulerOptions;
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* When false, an identity event sets the user and publishes without
|
|
58
|
+
* starting a warm-up mint — for hosts that drive every mint explicitly.
|
|
59
|
+
*/
|
|
57
60
|
readonly autoMint?: boolean;
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
|
-
* `pending` is the initial phase, before the
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
+
* `pending` is the initial phase, before the identity has delivered even
|
|
64
|
+
* once, so a host can tell "Firebase has not answered yet" (pending) from
|
|
65
|
+
* "nobody is signed in" (a delivered null) without wrapping the port.
|
|
63
66
|
*/
|
|
64
67
|
export type SessionSnapshot<TUser extends AccountUser = AccountUser> = {
|
|
65
68
|
readonly phase: 'pending';
|
|
@@ -83,21 +86,12 @@ export type SessionSnapshot<TUser extends AccountUser = AccountUser> = {
|
|
|
83
86
|
readonly session: undefined;
|
|
84
87
|
readonly failure: SessionFailure;
|
|
85
88
|
};
|
|
86
|
-
export interface AttachIdentityOptions {
|
|
87
|
-
/**
|
|
88
|
-
* When false, an identity event sets the user and publishes without
|
|
89
|
-
* starting a warm-up mint — for hosts that drive every mint explicitly.
|
|
90
|
-
*/
|
|
91
|
-
readonly autoMint?: boolean;
|
|
92
|
-
}
|
|
93
89
|
export interface SessionClient<TUser extends AccountUser = AccountUser> {
|
|
94
|
-
/** @deprecated Transitional Pinia-adapter seam; pass the identity as `createSessionClient`'s second argument instead (FE-2171, PoC #16639). */
|
|
95
|
-
attachIdentity: (identity: AccountIdentity<TUser>, options?: AttachIdentityOptions) => () => void;
|
|
96
90
|
/**
|
|
97
|
-
* Detaches the
|
|
98
|
-
* `clearStoredCredential`. Not terminal
|
|
99
|
-
*
|
|
100
|
-
*
|
|
91
|
+
* Detaches the constructed identity; the persisted credential stays until
|
|
92
|
+
* `clearStoredCredential`. Not terminal: an explicit-user mint issued after
|
|
93
|
+
* `dispose()` still commits and can re-arm the scheduler and cross-tab
|
|
94
|
+
* lease, so dispose and then stop calling the client.
|
|
101
95
|
*/
|
|
102
96
|
dispose: () => void;
|
|
103
97
|
getSnapshot: () => SessionSnapshot<TUser>;
|
|
@@ -133,7 +127,7 @@ export interface SessionClient<TUser extends AccountUser = AccountUser> {
|
|
|
133
127
|
invalidate: () => void;
|
|
134
128
|
/**
|
|
135
129
|
* Drops only the persisted copy of the credential. The published session
|
|
136
|
-
* stays live; a host that must end it
|
|
130
|
+
* stays live; a host that must end it disposes or invalidates as well.
|
|
137
131
|
*/
|
|
138
132
|
clearStoredCredential: () => void;
|
|
139
133
|
}
|
package/dist/core/session.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { DEFAULT_FRESH_MARGIN_MS, createCredentialCache, decodeAdopted, isCredentialFresh, selectFreshCredential } from './credentialCache.js';
|
|
2
|
-
import { isAccountIdentity } from './identity.js';
|
|
3
2
|
import { abortable, exchangeToken } from './exchange.js';
|
|
4
3
|
import { createMintCoordinator } from './mintCoordinator.js';
|
|
5
4
|
import { createRefreshScheduler } from './refreshScheduler.js';
|
|
@@ -28,9 +27,8 @@ const DEFAULT_MINT_TIMEOUT_MS = 15_000;
|
|
|
28
27
|
* production's default branch.
|
|
29
28
|
*/
|
|
30
29
|
export function createSessionClient(clientOptions, identity) {
|
|
31
|
-
const { exchangeUrl, storage, freshMarginMs = DEFAULT_FRESH_MARGIN_MS } = clientOptions;
|
|
30
|
+
const { exchangeUrl, storage, freshMarginMs = DEFAULT_FRESH_MARGIN_MS, autoMint = true } = clientOptions;
|
|
32
31
|
let state = initialSessionState();
|
|
33
|
-
let detachCurrent;
|
|
34
32
|
const listeners = new Set();
|
|
35
33
|
const cache = createCredentialCache(storage);
|
|
36
34
|
const mints = createMintCoordinator();
|
|
@@ -245,38 +243,30 @@ export function createSessionClient(clientOptions, identity) {
|
|
|
245
243
|
}, () => options.now?.() ?? hostNow());
|
|
246
244
|
return result;
|
|
247
245
|
}
|
|
248
|
-
function subscribeIdentity(port
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
detachCurrent?.();
|
|
253
|
-
let unsubscribe = () => undefined;
|
|
254
|
-
const detach = () => {
|
|
255
|
-
if (detachCurrent !== detach)
|
|
256
|
-
return;
|
|
257
|
-
detachCurrent = undefined;
|
|
258
|
-
unsubscribe();
|
|
259
|
-
commit({ type: 'identity-detached' });
|
|
260
|
-
};
|
|
261
|
-
detachCurrent = detach;
|
|
262
|
-
unsubscribe = port.onUserChanged((next) => {
|
|
263
|
-
if (detachCurrent !== detach)
|
|
246
|
+
function subscribeIdentity(port) {
|
|
247
|
+
let active = true;
|
|
248
|
+
const unsubscribe = port.onUserChanged((next) => {
|
|
249
|
+
if (!active)
|
|
264
250
|
return;
|
|
265
251
|
commit({ type: 'identity-changed', user: next });
|
|
266
|
-
|
|
252
|
+
// A listener may dispose the client or deliver a newer user inside
|
|
253
|
+
// that publish; either makes state.user no longer this delivery's.
|
|
254
|
+
if (next && autoMint && state.user === next) {
|
|
267
255
|
void refreshWith(ensureCore, next);
|
|
268
256
|
}
|
|
269
257
|
});
|
|
270
|
-
|
|
258
|
+
return () => {
|
|
259
|
+
if (!active)
|
|
260
|
+
return;
|
|
261
|
+
active = false;
|
|
271
262
|
unsubscribe();
|
|
272
|
-
|
|
263
|
+
commit({ type: 'identity-detached' });
|
|
264
|
+
};
|
|
273
265
|
}
|
|
274
|
-
|
|
275
|
-
subscribeIdentity(identity, clientOptions.autoMint !== false);
|
|
266
|
+
const detachIdentity = identity ? subscribeIdentity(identity) : undefined;
|
|
276
267
|
return {
|
|
277
|
-
attachIdentity: (port, attachOptions) => subscribeIdentity(port, attachOptions?.autoMint !== false),
|
|
278
268
|
dispose() {
|
|
279
|
-
|
|
269
|
+
detachIdentity?.();
|
|
280
270
|
},
|
|
281
271
|
getSnapshot,
|
|
282
272
|
subscribe(listener) {
|
|
@@ -85,14 +85,14 @@ export interface MintHandle {
|
|
|
85
85
|
* (`createWebCrossTabRefreshPort` from `@comfyorg/account-core/web`); tests pass
|
|
86
86
|
* fakes.
|
|
87
87
|
*/
|
|
88
|
-
export interface CrossTabRefreshPort {
|
|
88
|
+
export interface CrossTabRefreshPort<Message = AccountCredential> {
|
|
89
89
|
/**
|
|
90
90
|
* Queue for the key's lease. `onAcquired` fires if and when this tab
|
|
91
91
|
* becomes leader; the returned function abandons the request or releases
|
|
92
92
|
* held leadership.
|
|
93
93
|
*/
|
|
94
94
|
requestLeadership: (key: string, onAcquired: () => void) => () => void;
|
|
95
|
-
publishCredential: (key: string, credential:
|
|
95
|
+
publishCredential: (key: string, credential: Message) => void;
|
|
96
96
|
/** Messages cross a serialization boundary; the client validates them. */
|
|
97
97
|
onCredential: (key: string, callback: (message: unknown) => void) => () => void;
|
|
98
98
|
}
|
|
@@ -132,4 +132,41 @@ export interface RefreshSchedulerOptions {
|
|
|
132
132
|
*/
|
|
133
133
|
readonly onScheduledOutcome?: (report: ScheduledRefreshReport) => void;
|
|
134
134
|
}
|
|
135
|
+
/** The signed-in principal behind the shared `__Host-comfy_session` cookie. */
|
|
136
|
+
export interface WebSessionUser {
|
|
137
|
+
readonly id: string;
|
|
138
|
+
readonly email: string;
|
|
139
|
+
readonly name?: string;
|
|
140
|
+
readonly emailVerified: boolean;
|
|
141
|
+
readonly signInProvider?: string;
|
|
142
|
+
}
|
|
143
|
+
export interface WebSession {
|
|
144
|
+
readonly user: WebSessionUser;
|
|
145
|
+
readonly csrfToken: string;
|
|
146
|
+
/** ms since epoch; the sliding idle expiry */
|
|
147
|
+
readonly expiresAt: number;
|
|
148
|
+
/** ms since epoch; the hard cap regardless of activity */
|
|
149
|
+
readonly absoluteExpiresAt: number;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `SESSION_UNAVAILABLE` is the only transient code: 429, 5xx, network,
|
|
153
|
+
* abort, and unreadable bodies land there so an outage never reads as a
|
|
154
|
+
* sign-out. `SESSION_REQUEST_REFUSED` is a request no fresh session can fix.
|
|
155
|
+
*/
|
|
156
|
+
export type WebSessionErrorCode = 'NO_SESSION' | 'SESSION_EXPIRED' | 'SESSION_REVOKED' | 'CSRF_STALE' | 'IDENTITY_CHANGED' | 'WORKSPACE_ACCESS_DENIED' | 'SESSION_REQUEST_REFUSED' | 'SESSION_UNAVAILABLE';
|
|
157
|
+
export interface WebSessionFailure {
|
|
158
|
+
readonly status: 'error';
|
|
159
|
+
readonly code: WebSessionErrorCode;
|
|
160
|
+
readonly retryable: boolean;
|
|
161
|
+
readonly httpStatus?: number;
|
|
162
|
+
/** The server's `ErrorResponse.code`, when the body carried one. */
|
|
163
|
+
readonly serverCode?: string;
|
|
164
|
+
}
|
|
165
|
+
export type WebSessionResult = {
|
|
166
|
+
readonly status: 'ok';
|
|
167
|
+
readonly session: WebSession;
|
|
168
|
+
} | WebSessionFailure;
|
|
169
|
+
export type WebSessionCommandResult = {
|
|
170
|
+
readonly status: 'ok';
|
|
171
|
+
} | WebSessionFailure;
|
|
135
172
|
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AccountCredential, WebSession, WebSessionFailure } from './sessionContracts.js';
|
|
2
|
+
export interface SessionTokenMintOptions {
|
|
3
|
+
/** Ingest API root, e.g. `https://cloud.comfy.org/api`. */
|
|
4
|
+
readonly apiBaseUrl: string;
|
|
5
|
+
readonly fetchImpl: typeof fetch;
|
|
6
|
+
/** The live web session, or undefined when signed out. */
|
|
7
|
+
readonly getSession: () => WebSession | undefined;
|
|
8
|
+
readonly now?: () => number;
|
|
9
|
+
/** Re-mint once a cached token has this long left. Default 60s. */
|
|
10
|
+
readonly refreshBufferMs?: number;
|
|
11
|
+
/** Caps each mint, body included; a timeout is `SESSION_UNAVAILABLE`. Default: none. */
|
|
12
|
+
readonly timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionTokenFailure extends WebSessionFailure {
|
|
15
|
+
/** From a 429's `Retry-After`; no request is sent until it passes. */
|
|
16
|
+
readonly retryAfterMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export type SessionTokenResult = {
|
|
19
|
+
readonly status: 'ok';
|
|
20
|
+
readonly credential: AccountCredential;
|
|
21
|
+
} | SessionTokenFailure;
|
|
22
|
+
export declare class SessionTokenError extends Error {
|
|
23
|
+
readonly failure: SessionTokenFailure;
|
|
24
|
+
constructor(failure: SessionTokenFailure);
|
|
25
|
+
}
|
|
26
|
+
export interface SessionTokenMint {
|
|
27
|
+
/** Never throws: every outcome is a result. */
|
|
28
|
+
readonly mint: (workspaceId?: string) => Promise<SessionTokenResult>;
|
|
29
|
+
/** Like `mint`, but never serves the cached token; for the one 401 retry. */
|
|
30
|
+
readonly remint: (workspaceId?: string) => Promise<SessionTokenResult>;
|
|
31
|
+
/** For `createRequestAuthorizer`; rejects with a `SessionTokenError`. */
|
|
32
|
+
readonly getWorkspaceToken: (workspaceId?: string) => Promise<string>;
|
|
33
|
+
}
|
|
34
|
+
export declare const DEFAULT_REFRESH_BUFFER_MS = 60000;
|
|
35
|
+
export declare function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now, refreshBufferMs, timeoutMs }: SessionTokenMintOptions): SessionTokenMint;
|