@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.
Files changed (62) hide show
  1. package/dist/core/billing/billingContracts.d.ts +29 -14
  2. package/dist/core/billing/billingErrorBody.d.ts +8 -4
  3. package/dist/core/billing/billingErrorBody.js +14 -4
  4. package/dist/core/billing/credentialedTransport.d.ts +23 -7
  5. package/dist/core/billing/credentialedTransport.js +72 -8
  6. package/dist/core/billing/events.d.ts +47 -0
  7. package/dist/core/billing/events.js +43 -0
  8. package/dist/core/billing/index.d.ts +16 -11
  9. package/dist/core/billing/index.js +8 -6
  10. package/dist/core/billing/operationLifecycle.d.ts +34 -1
  11. package/dist/core/billing/operationLifecycle.js +146 -67
  12. package/dist/core/billing/operationPointer.d.ts +27 -2
  13. package/dist/core/billing/operationPointer.js +36 -25
  14. package/dist/core/billing/operationPolicy.d.ts +44 -7
  15. package/dist/core/billing/operationPolicy.js +64 -3
  16. package/dist/core/billing/operationState.d.ts +102 -18
  17. package/dist/core/billing/operationState.js +78 -8
  18. package/dist/core/billing/paymentCopy.d.ts +6 -1
  19. package/dist/core/billing/paymentCopy.js +34 -1
  20. package/dist/core/billing/paymentProjection.d.ts +14 -2
  21. package/dist/core/billing/paymentProjection.js +37 -9
  22. package/dist/core/billing/scopedReader.d.ts +11 -5
  23. package/dist/core/billing/scopedReader.js +20 -9
  24. package/dist/core/billing/sharedRead.js +4 -2
  25. package/dist/core/billing/subscriptionCommands.d.ts +37 -9
  26. package/dist/core/billing/subscriptionCommands.js +42 -30
  27. package/dist/core/billing/topup.d.ts +26 -0
  28. package/dist/core/billing/topup.js +36 -2
  29. package/dist/core/billing/wireCents.d.ts +8 -0
  30. package/dist/core/billing/wireCents.js +8 -0
  31. package/dist/core/billing/workspaceInvites.d.ts +12 -0
  32. package/dist/core/billing/workspaceInvites.js +26 -0
  33. package/dist/core/identity.d.ts +6 -6
  34. package/dist/core/identity.js +4 -10
  35. package/dist/core/lazyIdentity.d.ts +31 -0
  36. package/dist/core/lazyIdentity.js +96 -0
  37. package/dist/core/requestAuth.d.ts +30 -0
  38. package/dist/core/requestAuth.js +41 -0
  39. package/dist/core/session.d.ts +12 -18
  40. package/dist/core/session.js +16 -26
  41. package/dist/core/sessionContracts.d.ts +39 -2
  42. package/dist/core/sessionTokenMint.d.ts +33 -0
  43. package/dist/core/sessionTokenMint.js +205 -0
  44. package/dist/core/webSession.d.ts +25 -0
  45. package/dist/core/webSession.js +137 -0
  46. package/dist/core/webSessionFlag.d.ts +20 -0
  47. package/dist/core/webSessionFlag.js +62 -0
  48. package/dist/core/webSessionIdentity.d.ts +291 -0
  49. package/dist/core/webSessionIdentity.js +496 -0
  50. package/dist/firebase/configSource.d.ts +54 -0
  51. package/dist/firebase/configSource.js +111 -0
  52. package/dist/firebase/index.d.ts +71 -8
  53. package/dist/firebase/index.js +236 -23
  54. package/dist/firebase/popupWatch.d.ts +42 -0
  55. package/dist/firebase/popupWatch.js +140 -0
  56. package/dist/testing.d.ts +38 -0
  57. package/dist/testing.js +116 -0
  58. package/dist/web/crossTabRefresh.d.ts +4 -2
  59. package/dist/web/crossTabRefresh.js +13 -0
  60. package/dist/workspaceLink.d.ts +45 -0
  61. package/dist/workspaceLink.js +78 -0
  62. 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
+ }
@@ -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
- /** Applies to the identity passed at construction; see `AttachIdentityOptions.autoMint`. */
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 attached identity has delivered
61
- * even once, so a host can tell "Firebase has not answered yet" (pending)
62
- * from "nobody is signed in" (a delivered null) without wrapping the port.
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 current identity; the persisted credential stays until
98
- * `clearStoredCredential`. Not terminal, like a detach: an explicit-user
99
- * mint issued after `dispose()` still commits and can re-arm the scheduler
100
- * and cross-tab lease, so dispose and then stop calling the client.
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 detaches or invalidates as well.
130
+ * stays live; a host that must end it disposes or invalidates as well.
137
131
  */
138
132
  clearStoredCredential: () => void;
139
133
  }
@@ -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, autoMint) {
249
- if (!isAccountIdentity(port)) {
250
- throw new Error('the session client needs the identity from @comfyorg/account-core/firebase (or /testing)');
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
- if (detachCurrent === detach && next && autoMint) {
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
- if (detachCurrent !== detach)
258
+ return () => {
259
+ if (!active)
260
+ return;
261
+ active = false;
271
262
  unsubscribe();
272
- return detach;
263
+ commit({ type: 'identity-detached' });
264
+ };
273
265
  }
274
- if (identity)
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
- detachCurrent?.();
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: AccountCredential) => void;
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>;