@comfyorg/account-core 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/core/billing/balanceWatch.d.ts +35 -0
- package/dist/core/billing/balanceWatch.js +83 -0
- package/dist/core/billing/billingContracts.d.ts +123 -0
- package/dist/core/billing/billingContracts.js +11 -0
- package/dist/core/billing/billingErrorBody.d.ts +8 -0
- package/dist/core/billing/billingErrorBody.js +11 -0
- package/dist/core/billing/billingScope.d.ts +39 -0
- package/dist/core/billing/billingScope.js +45 -0
- package/dist/core/billing/capabilities.d.ts +70 -0
- package/dist/core/billing/capabilities.js +137 -0
- package/dist/core/billing/capabilityDenials.d.ts +26 -0
- package/dist/core/billing/capabilityDenials.js +43 -0
- package/dist/core/billing/challengeDriver.d.ts +22 -0
- package/dist/core/billing/challengeDriver.js +38 -0
- package/dist/core/billing/credentialedTransport.d.ts +35 -0
- package/dist/core/billing/credentialedTransport.js +53 -0
- package/dist/core/billing/credits.d.ts +43 -0
- package/dist/core/billing/credits.js +29 -0
- package/dist/core/billing/httpStatus.d.ts +7 -0
- package/dist/core/billing/httpStatus.js +25 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.js +23 -0
- package/dist/core/billing/operationLifecycle.d.ts +77 -0
- package/dist/core/billing/operationLifecycle.js +547 -0
- package/dist/core/billing/operationPointer.d.ts +48 -0
- package/dist/core/billing/operationPointer.js +89 -0
- package/dist/core/billing/operationPolicy.d.ts +26 -0
- package/dist/core/billing/operationPolicy.js +51 -0
- package/dist/core/billing/operationState.d.ts +128 -0
- package/dist/core/billing/operationState.js +187 -0
- package/dist/core/billing/paymentCopy.d.ts +18 -0
- package/dist/core/billing/paymentCopy.js +47 -0
- package/dist/core/billing/paymentMethods.d.ts +40 -0
- package/dist/core/billing/paymentMethods.js +37 -0
- package/dist/core/billing/paymentProjection.d.ts +34 -0
- package/dist/core/billing/paymentProjection.js +56 -0
- package/dist/core/billing/plans.d.ts +44 -0
- package/dist/core/billing/plans.js +29 -0
- package/dist/core/billing/presentation.d.ts +20 -0
- package/dist/core/billing/presentation.js +10 -0
- package/dist/core/billing/scopedReader.d.ts +61 -0
- package/dist/core/billing/scopedReader.js +108 -0
- package/dist/core/billing/sharedRead.d.ts +33 -0
- package/dist/core/billing/sharedRead.js +59 -0
- package/dist/core/billing/status.d.ts +27 -0
- package/dist/core/billing/status.js +21 -0
- package/dist/core/billing/subscriptionCommands.d.ts +367 -0
- package/dist/core/billing/subscriptionCommands.js +263 -0
- package/dist/core/billing/topup.d.ts +113 -0
- package/dist/core/billing/topup.js +209 -0
- package/dist/core/billing/transport.d.ts +14 -0
- package/dist/core/billing/transport.js +106 -0
- package/dist/core/billing/transportExchange.d.ts +24 -0
- package/dist/core/billing/transportExchange.js +79 -0
- package/dist/core/boundedOperation.d.ts +16 -0
- package/dist/core/boundedOperation.js +12 -0
- package/dist/core/credentialCache.d.ts +41 -0
- package/dist/core/credentialCache.js +97 -0
- package/dist/core/customerRecovery.d.ts +39 -0
- package/dist/core/customerRecovery.js +79 -0
- package/dist/core/exchange.d.ts +50 -0
- package/dist/core/exchange.js +137 -0
- package/dist/core/identity.d.ts +21 -0
- package/dist/core/identity.js +16 -0
- package/dist/core/mintCoordinator.d.ts +18 -0
- package/dist/core/mintCoordinator.js +31 -0
- package/dist/core/refreshScheduler.d.ts +46 -0
- package/dist/core/refreshScheduler.js +262 -0
- package/dist/core/session.d.ts +146 -0
- package/dist/core/session.js +312 -0
- package/dist/core/sessionContracts.d.ts +135 -0
- package/dist/core/sessionContracts.js +9 -0
- package/dist/core/sessionState.d.ts +118 -0
- package/dist/core/sessionState.js +235 -0
- package/dist/firebase/index.d.ts +53 -0
- package/dist/firebase/index.js +74 -0
- package/dist/firebaseAuthError.d.ts +52 -0
- package/dist/firebaseAuthError.js +88 -0
- package/dist/loadExternalScript.d.ts +7 -0
- package/dist/loadExternalScript.js +88 -0
- package/dist/provisioning.d.ts +53 -0
- package/dist/provisioning.js +76 -0
- package/dist/redirect.d.ts +13 -0
- package/dist/redirect.js +38 -0
- package/dist/signInSchemas.d.ts +88 -0
- package/dist/signInSchemas.js +71 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +26 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -0
- package/dist/turnstile.d.ts +14 -0
- package/dist/turnstile.js +17 -0
- package/dist/turnstileScript.d.ts +18 -0
- package/dist/turnstileScript.js +6 -0
- package/dist/web/crossTabRefresh.d.ts +18 -0
- package/dist/web/crossTabRefresh.js +121 -0
- package/dist/webviewDetection.d.ts +5 -0
- package/dist/webviewDetection.js +62 -0
- package/package.json +114 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CredentialResponseSchema } from './exchange.js';
|
|
3
|
+
/** The generated contract for POST /api/auth/token, never a local copy of it. */
|
|
4
|
+
const CachedCredentialSchema = CredentialResponseSchema.omit({
|
|
5
|
+
expires_at: true
|
|
6
|
+
}).extend({
|
|
7
|
+
// Storage and broadcast are untrusted boundaries: an empty token can never
|
|
8
|
+
// authorize and a non-finite expiry can never lapse, so neither is a session.
|
|
9
|
+
token: z.string().min(1),
|
|
10
|
+
expiresAt: z.number().finite(),
|
|
11
|
+
uid: z.string(),
|
|
12
|
+
/** The workspace target the credential was minted for; absent = personal. */
|
|
13
|
+
target: z.string().optional()
|
|
14
|
+
});
|
|
15
|
+
function toCredential(data) {
|
|
16
|
+
return {
|
|
17
|
+
token: data.token,
|
|
18
|
+
expiresAt: data.expiresAt,
|
|
19
|
+
uid: data.uid,
|
|
20
|
+
workspace: data.workspace,
|
|
21
|
+
role: data.role,
|
|
22
|
+
permissions: data.permissions
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function encodeCached(session, target) {
|
|
26
|
+
return JSON.stringify({ ...session, target });
|
|
27
|
+
}
|
|
28
|
+
export function decodeCached(raw, uid) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(raw);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const result = CachedCredentialSchema.safeParse(parsed);
|
|
37
|
+
if (!result.success || result.data.uid !== uid)
|
|
38
|
+
return undefined;
|
|
39
|
+
return { credential: toCredential(result.data), target: result.data.target };
|
|
40
|
+
}
|
|
41
|
+
export function decodeAdopted(message) {
|
|
42
|
+
const parsed = CachedCredentialSchema.safeParse(message);
|
|
43
|
+
return parsed.success ? toCredential(parsed.data) : undefined;
|
|
44
|
+
}
|
|
45
|
+
export const DEFAULT_FRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
46
|
+
export function isCredentialFresh(session, now, freshMarginMs = DEFAULT_FRESH_MARGIN_MS) {
|
|
47
|
+
return session.expiresAt - now > freshMarginMs;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The live credential is authoritative; storage is recovery state, not a
|
|
51
|
+
* competing source. Candidates are consulted in the order given (memory
|
|
52
|
+
* first, then storage) for this exact uid and target, and the first fresh one
|
|
53
|
+
* wins — expiry must not override this (a rejected token can outlive its
|
|
54
|
+
* shorter-lived replacement), and a target-less read must never adopt a team
|
|
55
|
+
* session.
|
|
56
|
+
*/
|
|
57
|
+
export function selectFreshCredential(candidates, uid, target, now, freshMarginMs) {
|
|
58
|
+
for (const supply of candidates) {
|
|
59
|
+
const candidate = supply();
|
|
60
|
+
if (candidate?.credential?.uid === uid &&
|
|
61
|
+
candidate.target === target &&
|
|
62
|
+
isCredentialFresh(candidate.credential, now, freshMarginMs)) {
|
|
63
|
+
return candidate.credential;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function createCredentialCache(storage) {
|
|
69
|
+
return {
|
|
70
|
+
read(uid) {
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = storage.read();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return raw === null ? undefined : decodeCached(raw, uid);
|
|
79
|
+
},
|
|
80
|
+
write(session, target) {
|
|
81
|
+
try {
|
|
82
|
+
storage.write(encodeCached(session, target));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
void 0;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
clear() {
|
|
89
|
+
try {
|
|
90
|
+
storage.clear();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
void 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing for accounts whose customer record was never provisioned.
|
|
3
|
+
*
|
|
4
|
+
* Customer creation runs after the Firebase session is established during
|
|
5
|
+
* sign-in/sign-up, so an interruption (navigation, closed tab, network
|
|
6
|
+
* failure) can leave a permanently signed-in user without a customer
|
|
7
|
+
* record. Sessions restored from persisted credentials never re-run the
|
|
8
|
+
* sign-in flow, so every /customers/* request fails with 409 and nothing
|
|
9
|
+
* ever retries the creation. The cloud app's rule, shared so every host
|
|
10
|
+
* heals the same way.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The auth middleware rejects requests for accounts without a customer
|
|
14
|
+
* record using this exact message. Business-level 409s from /customers/*
|
|
15
|
+
* endpoints (e.g. conflicting subscription state) must NOT trigger
|
|
16
|
+
* provisioning or a blind retry of a payment request.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MISSING_CUSTOMER_MESSAGE = "Failed to find customer";
|
|
19
|
+
export declare function isMissingCustomerResponse(response: Response): Promise<boolean>;
|
|
20
|
+
export declare function isCustomerEndpoint(input: string, base?: string): boolean;
|
|
21
|
+
export interface CustomerRecoveryDeps {
|
|
22
|
+
/** The request itself; run again, once, after a successful recovery. */
|
|
23
|
+
readonly request: () => Promise<Response>;
|
|
24
|
+
/** Provisions the customer; the host deduplicates concurrent callers. */
|
|
25
|
+
readonly recoverMissingCustomer: () => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* False once the signed-in identity is no longer the one that started the
|
|
28
|
+
* request: recovery and retry are then skipped and the 409 returned.
|
|
29
|
+
*/
|
|
30
|
+
readonly identityUnchanged?: () => boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The recovery above, applied only to /customers/* endpoints: a 409 from
|
|
34
|
+
* anywhere else is never a missing customer.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchWithCustomerRecovery(input: string, deps: CustomerRecoveryDeps & {
|
|
37
|
+
/** Resolves a relative `input`; a browser host passes its page href. */
|
|
38
|
+
readonly base?: string;
|
|
39
|
+
}): Promise<Response>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing for accounts whose customer record was never provisioned.
|
|
3
|
+
*
|
|
4
|
+
* Customer creation runs after the Firebase session is established during
|
|
5
|
+
* sign-in/sign-up, so an interruption (navigation, closed tab, network
|
|
6
|
+
* failure) can leave a permanently signed-in user without a customer
|
|
7
|
+
* record. Sessions restored from persisted credentials never re-run the
|
|
8
|
+
* sign-in flow, so every /customers/* request fails with 409 and nothing
|
|
9
|
+
* ever retries the creation. The cloud app's rule, shared so every host
|
|
10
|
+
* heals the same way.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The auth middleware rejects requests for accounts without a customer
|
|
14
|
+
* record using this exact message. Business-level 409s from /customers/*
|
|
15
|
+
* endpoints (e.g. conflicting subscription state) must NOT trigger
|
|
16
|
+
* provisioning or a blind retry of a payment request.
|
|
17
|
+
*/
|
|
18
|
+
export const MISSING_CUSTOMER_MESSAGE = 'Failed to find customer';
|
|
19
|
+
export async function isMissingCustomerResponse(response) {
|
|
20
|
+
if (response.status !== 409)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
const body = await response.clone().json();
|
|
24
|
+
return (typeof body === 'object' &&
|
|
25
|
+
body !== null &&
|
|
26
|
+
'message' in body &&
|
|
27
|
+
body.message === MISSING_CUSTOMER_MESSAGE);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function isCustomerEndpoint(input, base) {
|
|
34
|
+
try {
|
|
35
|
+
const { pathname } = new URL(input, base);
|
|
36
|
+
return pathname === '/customers' || pathname.startsWith('/customers/');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* On a missing-customer 409 this provisions the customer record and retries
|
|
44
|
+
* the original request a single time. If recovery fails, the original 409
|
|
45
|
+
* response is returned so callers surface their normal error handling.
|
|
46
|
+
*/
|
|
47
|
+
async function retryAfterCustomerRecovery(response, deps) {
|
|
48
|
+
const { request, recoverMissingCustomer, identityUnchanged = () => true } = deps;
|
|
49
|
+
if (!(await isMissingCustomerResponse(response)) || !identityUnchanged()) {
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
await recoverMissingCustomer();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.warn('Customer provisioning during 409 recovery failed; returning original response', error);
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
if (!identityUnchanged()) {
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return await request();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.warn('Retry after customer provisioning failed; returning original 409 response', error);
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The recovery above, applied only to /customers/* endpoints: a 409 from
|
|
72
|
+
* anywhere else is never a missing customer.
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchWithCustomerRecovery(input, deps) {
|
|
75
|
+
const response = await deps.request();
|
|
76
|
+
if (!isCustomerEndpoint(input, deps.base))
|
|
77
|
+
return response;
|
|
78
|
+
return retryAfterCustomerRecovery(response, deps);
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AccountUser, SessionResult } from './sessionContracts.js';
|
|
2
|
+
export declare const CredentialResponseSchema: import("zod").ZodObject<{
|
|
3
|
+
expires_at: import("zod").ZodString;
|
|
4
|
+
permissions: import("zod").ZodArray<import("zod").ZodString, "many">;
|
|
5
|
+
role: import("zod").ZodEnum<["owner", "member"]>;
|
|
6
|
+
token: import("zod").ZodString;
|
|
7
|
+
workspace: import("zod").ZodObject<{
|
|
8
|
+
id: import("zod").ZodString;
|
|
9
|
+
name: import("zod").ZodString;
|
|
10
|
+
type: import("zod").ZodEnum<["personal", "team"]>;
|
|
11
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
12
|
+
type: "personal" | "team";
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}, {
|
|
16
|
+
type: "personal" | "team";
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}>;
|
|
20
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
21
|
+
role: "owner" | "member";
|
|
22
|
+
expires_at: string;
|
|
23
|
+
token: string;
|
|
24
|
+
permissions: string[];
|
|
25
|
+
workspace: {
|
|
26
|
+
type: "personal" | "team";
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
};
|
|
30
|
+
}, {
|
|
31
|
+
role: "owner" | "member";
|
|
32
|
+
expires_at: string;
|
|
33
|
+
token: string;
|
|
34
|
+
permissions: string[];
|
|
35
|
+
workspace: {
|
|
36
|
+
type: "personal" | "team";
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
};
|
|
40
|
+
}>;
|
|
41
|
+
export declare function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>;
|
|
42
|
+
export interface ExchangeRequest {
|
|
43
|
+
readonly exchangeUrl: string;
|
|
44
|
+
readonly body: Record<string, unknown>;
|
|
45
|
+
readonly fetchImpl: typeof fetch;
|
|
46
|
+
readonly signal: AbortSignal | undefined;
|
|
47
|
+
readonly timeoutMs: number;
|
|
48
|
+
readonly now: () => number;
|
|
49
|
+
}
|
|
50
|
+
export declare function exchangeToken(user: AccountUser, request: ExchangeRequest): Promise<SessionResult>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure token exchange: POST the signed-in identity's Firebase token to
|
|
3
|
+
* the workspace JWT endpoint, parse the generated contract, and return a
|
|
4
|
+
* usable credential or a coded failure. No state, no storage, no identity
|
|
5
|
+
* ownership — the caller owns all of that and passes the request body (an
|
|
6
|
+
* empty body resolves the personal workspace; `{ workspace_id }` targets
|
|
7
|
+
* one). Cancelable by the caller's signal and bounded by a timeout, which
|
|
8
|
+
* the production store's timer-scheduled callers did not need.
|
|
9
|
+
*/
|
|
10
|
+
import { zExchangeTokenResponse } from '@comfyorg/ingest-types/zod';
|
|
11
|
+
export const CredentialResponseSchema = zExchangeTokenResponse;
|
|
12
|
+
/** 401/403/404 carry their own codes; everything else is one failure bucket. */
|
|
13
|
+
function codeForResponse(status) {
|
|
14
|
+
if (status === 401)
|
|
15
|
+
return 'INVALID_FIREBASE_TOKEN';
|
|
16
|
+
if (status === 403)
|
|
17
|
+
return 'ACCESS_DENIED';
|
|
18
|
+
if (status === 404)
|
|
19
|
+
return 'WORKSPACE_NOT_FOUND';
|
|
20
|
+
return 'TOKEN_EXCHANGE_FAILED';
|
|
21
|
+
}
|
|
22
|
+
export function abortable(promise, signal) {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
|
25
|
+
}
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
|
|
28
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
29
|
+
promise.then((value) => {
|
|
30
|
+
signal.removeEventListener('abort', onAbort);
|
|
31
|
+
resolve(value);
|
|
32
|
+
}, (error) => {
|
|
33
|
+
signal.removeEventListener('abort', onAbort);
|
|
34
|
+
reject(error);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function exchangeToken(user, request) {
|
|
39
|
+
const { exchangeUrl, body, fetchImpl, signal, timeoutMs, now } = request;
|
|
40
|
+
if (signal?.aborted) {
|
|
41
|
+
return { status: 'error', code: 'TOKEN_EXCHANGE_FAILED' };
|
|
42
|
+
}
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const abort = () => controller.abort();
|
|
45
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
46
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
let idToken;
|
|
49
|
+
try {
|
|
50
|
+
idToken = await abortable(user.getIdToken(), controller.signal);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// A missing identity token is NOT_AUTHENTICATED; an identity failure
|
|
54
|
+
// carrying that code keeps it, anything else (including our own abort)
|
|
55
|
+
// stays in the transient bucket.
|
|
56
|
+
const coded = !controller.signal.aborted &&
|
|
57
|
+
typeof error === 'object' &&
|
|
58
|
+
error !== null &&
|
|
59
|
+
'code' in error &&
|
|
60
|
+
error.code === 'NOT_AUTHENTICATED';
|
|
61
|
+
return {
|
|
62
|
+
status: 'error',
|
|
63
|
+
code: coded ? 'NOT_AUTHENTICATED' : 'TOKEN_EXCHANGE_FAILED'
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchImpl(exchangeUrl, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${idToken}`,
|
|
72
|
+
'Content-Type': 'application/json'
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: controller.signal
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { status: 'error', code: 'TOKEN_EXCHANGE_FAILED' };
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
return {
|
|
83
|
+
status: 'error',
|
|
84
|
+
code: codeForResponse(response.status),
|
|
85
|
+
httpStatus: response.status
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
let rawBody;
|
|
89
|
+
try {
|
|
90
|
+
// The timeout stays armed through the body read: headers arriving does
|
|
91
|
+
// not bound the body, and a stalled body must abort like a stalled connect.
|
|
92
|
+
rawBody = await abortable(response.json(), controller.signal);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return {
|
|
96
|
+
status: 'error',
|
|
97
|
+
code: 'TOKEN_EXCHANGE_FAILED',
|
|
98
|
+
httpStatus: response.status
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const parsed = CredentialResponseSchema.safeParse(rawBody);
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
return {
|
|
104
|
+
status: 'error',
|
|
105
|
+
code: 'TOKEN_EXCHANGE_FAILED',
|
|
106
|
+
httpStatus: response.status
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Date.parse can yield NaN on a schema-valid string, so the expiry is
|
|
110
|
+
// checked after the schema. A token dead on arrival can never authorize
|
|
111
|
+
// anything; a short-but-future one is left to the valid-on-read path; an
|
|
112
|
+
// empty token is never a session.
|
|
113
|
+
const expiresAt = Date.parse(parsed.data.expires_at);
|
|
114
|
+
if (Number.isNaN(expiresAt) ||
|
|
115
|
+
parsed.data.token === '' ||
|
|
116
|
+
expiresAt <= now()) {
|
|
117
|
+
return {
|
|
118
|
+
status: 'error',
|
|
119
|
+
code: 'TOKEN_EXCHANGE_FAILED',
|
|
120
|
+
httpStatus: response.status
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const session = {
|
|
124
|
+
token: parsed.data.token,
|
|
125
|
+
expiresAt,
|
|
126
|
+
uid: user.uid,
|
|
127
|
+
workspace: parsed.data.workspace,
|
|
128
|
+
role: parsed.data.role,
|
|
129
|
+
permissions: parsed.data.permissions
|
|
130
|
+
};
|
|
131
|
+
return { status: 'ok', session };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
signal?.removeEventListener('abort', abort);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AccountUser } from './sessionContracts.js';
|
|
2
|
+
/**
|
|
3
|
+
* The identity boundary. An internal port, not a host adapter: real hosts
|
|
4
|
+
* get their implementation from `@comfyorg/account-core/firebase`; tests brand a
|
|
5
|
+
* fake through `@comfyorg/account-core/testing`. `attachIdentity` accepts only
|
|
6
|
+
* the branded form.
|
|
7
|
+
*/
|
|
8
|
+
export interface IdentityPort<TUser extends AccountUser = AccountUser> {
|
|
9
|
+
onUserChanged: (callback: (user: TUser | null) => void) => () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Only the package's own identity entry (and the `./testing` seam) can mint
|
|
13
|
+
* an identity the session client accepts. A host cannot hand in its own
|
|
14
|
+
* provider: the symbol lives in a module the exports map never exposes.
|
|
15
|
+
*/
|
|
16
|
+
export declare const identityBrand: unique symbol;
|
|
17
|
+
export interface AccountIdentity<TUser extends AccountUser = AccountUser> extends IdentityPort<TUser> {
|
|
18
|
+
readonly [identityBrand]: true;
|
|
19
|
+
}
|
|
20
|
+
export declare function brandIdentity<TUser extends AccountUser>(port: IdentityPort<TUser>): AccountIdentity<TUser>;
|
|
21
|
+
export declare function isAccountIdentity(value: unknown): value is AccountIdentity;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Only the package's own identity entry (and the `./testing` seam) can mint
|
|
3
|
+
* an identity the session client accepts. A host cannot hand in its own
|
|
4
|
+
* provider: the symbol lives in a module the exports map never exposes.
|
|
5
|
+
*/
|
|
6
|
+
export const identityBrand = Symbol('@comfyorg/account-core identity');
|
|
7
|
+
export function brandIdentity(port) {
|
|
8
|
+
return { ...port, [identityBrand]: true };
|
|
9
|
+
}
|
|
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,18 @@
|
|
|
1
|
+
import type { MintHandle, SessionResult } from './sessionContracts.js';
|
|
2
|
+
export type MintDispatch = MintHandle & {
|
|
3
|
+
readonly joined: boolean;
|
|
4
|
+
};
|
|
5
|
+
export interface InFlightMint {
|
|
6
|
+
readonly promise: Promise<SessionResult>;
|
|
7
|
+
readonly uid: string;
|
|
8
|
+
readonly target: string | undefined;
|
|
9
|
+
readonly forced: boolean;
|
|
10
|
+
readonly mintId: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function canJoin(inFlight: InFlightMint | undefined, uid: string, target: string | undefined, forced: boolean): inFlight is InFlightMint;
|
|
13
|
+
interface MintCoordinator {
|
|
14
|
+
dispatch: (uid: string, target: string | undefined, forced: boolean, start: () => MintHandle) => MintDispatch;
|
|
15
|
+
abandon: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createMintCoordinator(): MintCoordinator;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function canJoin(inFlight, uid, target, forced) {
|
|
2
|
+
return (inFlight !== undefined &&
|
|
3
|
+
inFlight.uid === uid &&
|
|
4
|
+
inFlight.target === target &&
|
|
5
|
+
(!forced || inFlight.forced));
|
|
6
|
+
}
|
|
7
|
+
export function createMintCoordinator() {
|
|
8
|
+
let inFlight;
|
|
9
|
+
return {
|
|
10
|
+
dispatch(uid, target, forced, start) {
|
|
11
|
+
if (canJoin(inFlight, uid, target, forced)) {
|
|
12
|
+
return {
|
|
13
|
+
mintId: inFlight.mintId,
|
|
14
|
+
response: inFlight.promise,
|
|
15
|
+
joined: true
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const { mintId, response } = start();
|
|
19
|
+
const running = response.finally(() => {
|
|
20
|
+
if (inFlight?.promise !== running)
|
|
21
|
+
return;
|
|
22
|
+
inFlight = undefined;
|
|
23
|
+
});
|
|
24
|
+
inFlight = { promise: running, uid, target, forced, mintId };
|
|
25
|
+
return { mintId, response: running, joined: false };
|
|
26
|
+
},
|
|
27
|
+
abandon() {
|
|
28
|
+
inFlight = undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proactive-refresh owner: an optional piece the session client hands
|
|
3
|
+
* its lifecycle to when a host asks for scheduled refresh. It owns its own
|
|
4
|
+
* timers, retry count, the expiry fail-close, and (when a cross-tab port is
|
|
5
|
+
* given) the lease, leadership and sibling adoption. It never holds session
|
|
6
|
+
* state: it reads the client's live credential through the host and commits
|
|
7
|
+
* a refreshed, failed, expired or adopted result back through the host, so
|
|
8
|
+
* the client stays the single owner of `credential`/`failure`.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from createSessionClient so the mint/commit core can be reasoned
|
|
11
|
+
* about without the scheduling and cross-tab machinery, and so a client with
|
|
12
|
+
* no `refreshScheduler` option carries none of it.
|
|
13
|
+
*/
|
|
14
|
+
import type { AccountCredential, AccountUser, MintHandle, RefreshSchedulerOptions, SessionFailure } from './sessionContracts.js';
|
|
15
|
+
export declare const DEFAULT_BUFFER_MS: number;
|
|
16
|
+
/** Guards captured before a scheduled mint, re-checked before its commit. */
|
|
17
|
+
interface RefreshGuards {
|
|
18
|
+
readonly epoch: number;
|
|
19
|
+
readonly invalidation: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RefreshHost {
|
|
22
|
+
now: () => number;
|
|
23
|
+
getCurrentUser: () => AccountUser | null;
|
|
24
|
+
getCredential: () => AccountCredential | undefined;
|
|
25
|
+
captureGuards: () => RefreshGuards;
|
|
26
|
+
/** A scheduled mint's result may commit only if nothing superseded it. */
|
|
27
|
+
guardsHold: (guards: RefreshGuards, user: AccountUser, mintId: number) => boolean;
|
|
28
|
+
/** A forced mint for the target that produced the live credential. */
|
|
29
|
+
mint: (user: AccountUser) => MintHandle;
|
|
30
|
+
/** Returns a failure when the refreshed session was rejected (wrong scope). */
|
|
31
|
+
commitRefreshed: (session: AccountCredential, mintId: number) => SessionFailure | undefined;
|
|
32
|
+
commitPermanentFailure: (failure: SessionFailure) => void;
|
|
33
|
+
/** Clears iff the live credential is still `expiring`; returns what it committed. */
|
|
34
|
+
commitExpired: (expiring: AccountCredential) => SessionFailure | undefined;
|
|
35
|
+
/** Parse a cross-tab message into a credential, or undefined if malformed. */
|
|
36
|
+
parseAdopted: (message: unknown) => AccountCredential | undefined;
|
|
37
|
+
/** Commit a sibling's credential: supersede any in-flight mint, persist, publish. */
|
|
38
|
+
commitAdopted: (session: AccountCredential) => void;
|
|
39
|
+
}
|
|
40
|
+
export interface RefreshScheduler {
|
|
41
|
+
/** A fresh credential committed: cancel the fail-close, arm the next refresh, tell siblings. */
|
|
42
|
+
armAfterCommit: (session: AccountCredential, now: number) => void;
|
|
43
|
+
stop: () => void;
|
|
44
|
+
}
|
|
45
|
+
export declare function createRefreshScheduler(options: RefreshSchedulerOptions, host: RefreshHost): RefreshScheduler;
|
|
46
|
+
export {};
|