@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.1
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 +102 -18
- package/dist/core/billing/operationState.js +78 -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 +37 -9
- package/dist/core/billing/subscriptionCommands.js +42 -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/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 +33 -0
- package/dist/core/sessionTokenMint.js +205 -0
- package/dist/core/webSession.d.ts +25 -0
- package/dist/core/webSession.js +137 -0
- package/dist/core/webSessionFlag.d.ts +20 -0
- package/dist/core/webSessionFlag.js +62 -0
- package/dist/core/webSessionIdentity.d.ts +291 -0
- package/dist/core/webSessionIdentity.js +496 -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
|
@@ -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
|
+
}
|
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,33 @@
|
|
|
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
|
+
}
|
|
12
|
+
export interface SessionTokenFailure extends WebSessionFailure {
|
|
13
|
+
/** From a 429's `Retry-After`; no request is sent until it passes. */
|
|
14
|
+
readonly retryAfterMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export type SessionTokenResult = {
|
|
17
|
+
readonly status: 'ok';
|
|
18
|
+
readonly credential: AccountCredential;
|
|
19
|
+
} | SessionTokenFailure;
|
|
20
|
+
export declare class SessionTokenError extends Error {
|
|
21
|
+
readonly failure: SessionTokenFailure;
|
|
22
|
+
constructor(failure: SessionTokenFailure);
|
|
23
|
+
}
|
|
24
|
+
export interface SessionTokenMint {
|
|
25
|
+
/** Never throws: every outcome is a result. */
|
|
26
|
+
readonly mint: (workspaceId?: string) => Promise<SessionTokenResult>;
|
|
27
|
+
/** Like `mint`, but never serves the cached token; for the one 401 retry. */
|
|
28
|
+
readonly remint: (workspaceId?: string) => Promise<SessionTokenResult>;
|
|
29
|
+
/** For `createRequestAuthorizer`; rejects with a `SessionTokenError`. */
|
|
30
|
+
readonly getWorkspaceToken: (workspaceId?: string) => Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
export declare const DEFAULT_REFRESH_BUFFER_MS = 60000;
|
|
33
|
+
export declare function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now, refreshBufferMs }: SessionTokenMintOptions): SessionTokenMint;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace tokens for services that cannot read the session cookie. The
|
|
3
|
+
* session mints one at `POST /auth/token` on first use, and each token is
|
|
4
|
+
* reused per (session user, workspace) until it nears expiry. Failures come
|
|
5
|
+
* back as web-session codes; nothing here retries or signs out.
|
|
6
|
+
*/
|
|
7
|
+
import { zErrorResponse, zExchangeTokenResponse } from '@comfyorg/ingest-types/zod';
|
|
8
|
+
import { isCredentialFresh } from './credentialCache.js';
|
|
9
|
+
import { COMFY_CLIENT } from './requestAuth.js';
|
|
10
|
+
export class SessionTokenError extends Error {
|
|
11
|
+
failure;
|
|
12
|
+
constructor(failure) {
|
|
13
|
+
super(`Workspace token mint failed: ${failure.code}`);
|
|
14
|
+
this.failure = failure;
|
|
15
|
+
this.name = 'SessionTokenError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const DEFAULT_REFRESH_BUFFER_MS = 60_000;
|
|
19
|
+
const MAX_RETRY_AFTER_MS = 10 * 60 * 1000;
|
|
20
|
+
const STATUS_RULES = {
|
|
21
|
+
401: {
|
|
22
|
+
byServerCode: {
|
|
23
|
+
session_expired: 'SESSION_EXPIRED',
|
|
24
|
+
session_revoked: 'SESSION_REVOKED'
|
|
25
|
+
},
|
|
26
|
+
fallback: 'NO_SESSION'
|
|
27
|
+
},
|
|
28
|
+
403: {
|
|
29
|
+
byServerCode: {
|
|
30
|
+
csrf_invalid: 'CSRF_STALE',
|
|
31
|
+
workspace_access_denied: 'WORKSPACE_ACCESS_DENIED'
|
|
32
|
+
},
|
|
33
|
+
fallback: 'SESSION_REQUEST_REFUSED'
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const zServerCode = zErrorResponse.pick({ code: true });
|
|
37
|
+
function failure(code, httpStatus, serverCode) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'error',
|
|
40
|
+
code,
|
|
41
|
+
retryable: code === 'SESSION_UNAVAILABLE',
|
|
42
|
+
...(httpStatus === undefined ? {} : { httpStatus }),
|
|
43
|
+
...(serverCode === undefined ? {} : { serverCode })
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function readJson(response) {
|
|
47
|
+
try {
|
|
48
|
+
return await response.json();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Retry-After is delay-seconds or an HTTP-date; either is capped. */
|
|
55
|
+
function retryAfterMs(value, nowMs) {
|
|
56
|
+
if (value === null)
|
|
57
|
+
return undefined;
|
|
58
|
+
const seconds = Number(value);
|
|
59
|
+
const delayMs = Number.isFinite(seconds)
|
|
60
|
+
? seconds * 1000
|
|
61
|
+
: Date.parse(value) - nowMs;
|
|
62
|
+
return delayMs > 0 ? Math.min(delayMs, MAX_RETRY_AFTER_MS) : undefined;
|
|
63
|
+
}
|
|
64
|
+
function rateLimited(response, nowMs) {
|
|
65
|
+
const limited = failure('SESSION_UNAVAILABLE', response.status);
|
|
66
|
+
const waitMs = retryAfterMs(response.headers.get('Retry-After'), nowMs);
|
|
67
|
+
return waitMs === undefined ? limited : { ...limited, retryAfterMs: waitMs };
|
|
68
|
+
}
|
|
69
|
+
function codeFor(status, serverCode) {
|
|
70
|
+
const rule = STATUS_RULES[status];
|
|
71
|
+
if (rule === undefined)
|
|
72
|
+
return 'SESSION_REQUEST_REFUSED';
|
|
73
|
+
return rule.byServerCode[serverCode ?? ''] ?? rule.fallback;
|
|
74
|
+
}
|
|
75
|
+
async function classifyFailure(response, nowMs) {
|
|
76
|
+
const { status } = response;
|
|
77
|
+
if (status === 429)
|
|
78
|
+
return rateLimited(response, nowMs);
|
|
79
|
+
if (status >= 500)
|
|
80
|
+
return failure('SESSION_UNAVAILABLE', status);
|
|
81
|
+
const parsed = zServerCode.safeParse(await readJson(response));
|
|
82
|
+
const serverCode = parsed.success ? parsed.data.code : undefined;
|
|
83
|
+
return failure(codeFor(status, serverCode), status, serverCode);
|
|
84
|
+
}
|
|
85
|
+
export function createSessionTokenMint({ apiBaseUrl, fetchImpl, getSession, now = Date.now, refreshBufferMs = DEFAULT_REFRESH_BUFFER_MS }) {
|
|
86
|
+
const tokenUrl = `${apiBaseUrl.replace(/\/+$/, '')}/auth/token`;
|
|
87
|
+
/** Keyed by workspace; every entry belongs to `cacheOwner`. */
|
|
88
|
+
const cache = new Map();
|
|
89
|
+
const inFlight = new Map();
|
|
90
|
+
let cacheOwner;
|
|
91
|
+
/** Bumped on every owner change, so an older owner's answer never lands. */
|
|
92
|
+
let ownerGeneration = 0;
|
|
93
|
+
let rateLimit;
|
|
94
|
+
function adoptOwner(userId) {
|
|
95
|
+
if (cacheOwner === userId)
|
|
96
|
+
return;
|
|
97
|
+
cacheOwner = userId;
|
|
98
|
+
ownerGeneration += 1;
|
|
99
|
+
cache.clear();
|
|
100
|
+
inFlight.clear();
|
|
101
|
+
rateLimit = undefined;
|
|
102
|
+
}
|
|
103
|
+
async function request(session, workspaceId) {
|
|
104
|
+
let response;
|
|
105
|
+
try {
|
|
106
|
+
response = await fetchImpl(tokenUrl, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
credentials: 'include',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
'X-Comfy-Client': COMFY_CLIENT,
|
|
112
|
+
'X-CSRF-Token': session.csrfToken,
|
|
113
|
+
...(workspaceId === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: { 'X-Comfy-Workspace-ID': workspaceId })
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify(workspaceId === undefined ? {} : { workspace_id: workspaceId })
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return failure('SESSION_UNAVAILABLE');
|
|
122
|
+
}
|
|
123
|
+
if (!response.ok)
|
|
124
|
+
return classifyFailure(response, now());
|
|
125
|
+
const parsed = zExchangeTokenResponse.safeParse(await readJson(response));
|
|
126
|
+
const expiresAt = parsed.success
|
|
127
|
+
? Date.parse(parsed.data.expires_at)
|
|
128
|
+
: Number.NaN;
|
|
129
|
+
if (!parsed.success || parsed.data.token === '' || !(expiresAt > now())) {
|
|
130
|
+
return failure('SESSION_UNAVAILABLE', response.status);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
status: 'ok',
|
|
134
|
+
credential: {
|
|
135
|
+
token: parsed.data.token,
|
|
136
|
+
expiresAt: Math.min(expiresAt, session.expiresAt),
|
|
137
|
+
uid: session.user.id,
|
|
138
|
+
workspace: parsed.data.workspace,
|
|
139
|
+
role: parsed.data.role,
|
|
140
|
+
permissions: parsed.data.permissions
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function commit(workspaceId, generation, result) {
|
|
145
|
+
if (generation !== ownerGeneration)
|
|
146
|
+
return;
|
|
147
|
+
if (result.status === 'ok') {
|
|
148
|
+
cache.set(workspaceId, result.credential);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (result.httpStatus === 401)
|
|
152
|
+
cache.clear();
|
|
153
|
+
if (result.retryAfterMs !== undefined) {
|
|
154
|
+
rateLimit = { until: now() + result.retryAfterMs, failure: result };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function mint(workspaceId) {
|
|
158
|
+
const session = getSession();
|
|
159
|
+
adoptOwner(session?.user.id);
|
|
160
|
+
if (session === undefined)
|
|
161
|
+
return failure('NO_SESSION');
|
|
162
|
+
const userId = session.user.id;
|
|
163
|
+
// The cache is checked before the backoff on purpose: a caller already
|
|
164
|
+
// holding a fresh token is still served while new mints wait.
|
|
165
|
+
const cached = cache.get(workspaceId);
|
|
166
|
+
if (cached && isCredentialFresh(cached, now(), refreshBufferMs)) {
|
|
167
|
+
return { status: 'ok', credential: cached };
|
|
168
|
+
}
|
|
169
|
+
if (rateLimit && now() < rateLimit.until) {
|
|
170
|
+
return rateLimit.failure;
|
|
171
|
+
}
|
|
172
|
+
const joined = inFlight.get(workspaceId);
|
|
173
|
+
if (joined)
|
|
174
|
+
return joined;
|
|
175
|
+
const generation = ownerGeneration;
|
|
176
|
+
const running = request(session, workspaceId).then((result) => {
|
|
177
|
+
if (inFlight.get(workspaceId) === running)
|
|
178
|
+
inFlight.delete(workspaceId);
|
|
179
|
+
const current = getSession();
|
|
180
|
+
if (current === undefined)
|
|
181
|
+
return failure('NO_SESSION');
|
|
182
|
+
if (current.user.id !== userId)
|
|
183
|
+
return failure('IDENTITY_CHANGED');
|
|
184
|
+
commit(workspaceId, generation, result);
|
|
185
|
+
return result;
|
|
186
|
+
});
|
|
187
|
+
inFlight.set(workspaceId, running);
|
|
188
|
+
return running;
|
|
189
|
+
}
|
|
190
|
+
function remint(workspaceId) {
|
|
191
|
+
if (!inFlight.has(workspaceId))
|
|
192
|
+
cache.delete(workspaceId);
|
|
193
|
+
return mint(workspaceId);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
mint,
|
|
197
|
+
remint,
|
|
198
|
+
async getWorkspaceToken(workspaceId) {
|
|
199
|
+
const result = await mint(workspaceId);
|
|
200
|
+
if (result.status === 'ok')
|
|
201
|
+
return result.credential.token;
|
|
202
|
+
throw new SessionTokenError(result);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebSessionCommandResult, WebSessionResult } from './sessionContracts.js';
|
|
2
|
+
export type { WebSession, WebSessionCommandResult, WebSessionErrorCode, WebSessionFailure, WebSessionResult, WebSessionUser } from './sessionContracts.js';
|
|
3
|
+
export interface WebSessionOptions {
|
|
4
|
+
/** Ingest API root, e.g. `https://cloud.comfy.org/api`. */
|
|
5
|
+
readonly apiBaseUrl: string;
|
|
6
|
+
readonly fetchImpl: typeof fetch;
|
|
7
|
+
readonly signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
export declare function readWebSession(options: WebSessionOptions, { expectedUserId }?: {
|
|
10
|
+
readonly expectedUserId?: string;
|
|
11
|
+
}): Promise<WebSessionResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Creates the session, then reads it back for the user and CSRF token. A
|
|
14
|
+
* failure to produce the proof is the provider's error and propagates.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createWebSession(options: WebSessionOptions, getIdentityProof: () => Promise<string>, { expectedUserId }?: {
|
|
17
|
+
readonly expectedUserId?: string;
|
|
18
|
+
}): Promise<WebSessionResult>;
|
|
19
|
+
/** Sends no CSRF token: sign-out must work against a dead session. */
|
|
20
|
+
export declare function deleteWebSession(options: WebSessionOptions): Promise<WebSessionCommandResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Signs the user out of every device. Only a body the contract recognises is
|
|
23
|
+
* ok: the caller tells the user every device is signed out on that answer.
|
|
24
|
+
*/
|
|
25
|
+
export declare function revokeAllWebSessions(options: WebSessionOptions, csrfToken: string, getIdentityProof: () => Promise<string>): Promise<WebSessionCommandResult>;
|