@comfyorg/account-core 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/billing/balanceWatch.d.ts +35 -0
  3. package/dist/core/billing/balanceWatch.js +83 -0
  4. package/dist/core/billing/billingContracts.d.ts +123 -0
  5. package/dist/core/billing/billingContracts.js +11 -0
  6. package/dist/core/billing/billingErrorBody.d.ts +8 -0
  7. package/dist/core/billing/billingErrorBody.js +11 -0
  8. package/dist/core/billing/billingScope.d.ts +39 -0
  9. package/dist/core/billing/billingScope.js +45 -0
  10. package/dist/core/billing/capabilities.d.ts +70 -0
  11. package/dist/core/billing/capabilities.js +137 -0
  12. package/dist/core/billing/capabilityDenials.d.ts +26 -0
  13. package/dist/core/billing/capabilityDenials.js +43 -0
  14. package/dist/core/billing/challengeDriver.d.ts +22 -0
  15. package/dist/core/billing/challengeDriver.js +38 -0
  16. package/dist/core/billing/credentialedTransport.d.ts +35 -0
  17. package/dist/core/billing/credentialedTransport.js +53 -0
  18. package/dist/core/billing/credits.d.ts +43 -0
  19. package/dist/core/billing/credits.js +29 -0
  20. package/dist/core/billing/httpStatus.d.ts +7 -0
  21. package/dist/core/billing/httpStatus.js +25 -0
  22. package/dist/core/billing/index.d.ts +52 -0
  23. package/dist/core/billing/index.js +23 -0
  24. package/dist/core/billing/operationLifecycle.d.ts +77 -0
  25. package/dist/core/billing/operationLifecycle.js +547 -0
  26. package/dist/core/billing/operationPointer.d.ts +48 -0
  27. package/dist/core/billing/operationPointer.js +89 -0
  28. package/dist/core/billing/operationPolicy.d.ts +26 -0
  29. package/dist/core/billing/operationPolicy.js +51 -0
  30. package/dist/core/billing/operationState.d.ts +128 -0
  31. package/dist/core/billing/operationState.js +187 -0
  32. package/dist/core/billing/paymentCopy.d.ts +18 -0
  33. package/dist/core/billing/paymentCopy.js +47 -0
  34. package/dist/core/billing/paymentMethods.d.ts +40 -0
  35. package/dist/core/billing/paymentMethods.js +37 -0
  36. package/dist/core/billing/paymentProjection.d.ts +34 -0
  37. package/dist/core/billing/paymentProjection.js +56 -0
  38. package/dist/core/billing/plans.d.ts +44 -0
  39. package/dist/core/billing/plans.js +29 -0
  40. package/dist/core/billing/presentation.d.ts +20 -0
  41. package/dist/core/billing/presentation.js +10 -0
  42. package/dist/core/billing/scopedReader.d.ts +61 -0
  43. package/dist/core/billing/scopedReader.js +108 -0
  44. package/dist/core/billing/sharedRead.d.ts +33 -0
  45. package/dist/core/billing/sharedRead.js +59 -0
  46. package/dist/core/billing/status.d.ts +27 -0
  47. package/dist/core/billing/status.js +21 -0
  48. package/dist/core/billing/subscriptionCommands.d.ts +367 -0
  49. package/dist/core/billing/subscriptionCommands.js +263 -0
  50. package/dist/core/billing/topup.d.ts +113 -0
  51. package/dist/core/billing/topup.js +209 -0
  52. package/dist/core/billing/transport.d.ts +14 -0
  53. package/dist/core/billing/transport.js +106 -0
  54. package/dist/core/billing/transportExchange.d.ts +24 -0
  55. package/dist/core/billing/transportExchange.js +79 -0
  56. package/dist/core/boundedOperation.d.ts +16 -0
  57. package/dist/core/boundedOperation.js +12 -0
  58. package/dist/core/credentialCache.d.ts +41 -0
  59. package/dist/core/credentialCache.js +97 -0
  60. package/dist/core/customerRecovery.d.ts +39 -0
  61. package/dist/core/customerRecovery.js +79 -0
  62. package/dist/core/exchange.d.ts +50 -0
  63. package/dist/core/exchange.js +137 -0
  64. package/dist/core/identity.d.ts +21 -0
  65. package/dist/core/identity.js +16 -0
  66. package/dist/core/mintCoordinator.d.ts +18 -0
  67. package/dist/core/mintCoordinator.js +31 -0
  68. package/dist/core/refreshScheduler.d.ts +46 -0
  69. package/dist/core/refreshScheduler.js +262 -0
  70. package/dist/core/session.d.ts +146 -0
  71. package/dist/core/session.js +312 -0
  72. package/dist/core/sessionContracts.d.ts +135 -0
  73. package/dist/core/sessionContracts.js +9 -0
  74. package/dist/core/sessionState.d.ts +118 -0
  75. package/dist/core/sessionState.js +235 -0
  76. package/dist/firebase/index.d.ts +53 -0
  77. package/dist/firebase/index.js +74 -0
  78. package/dist/firebaseAuthError.d.ts +52 -0
  79. package/dist/firebaseAuthError.js +88 -0
  80. package/dist/loadExternalScript.d.ts +7 -0
  81. package/dist/loadExternalScript.js +88 -0
  82. package/dist/provisioning.d.ts +53 -0
  83. package/dist/provisioning.js +76 -0
  84. package/dist/redirect.d.ts +13 -0
  85. package/dist/redirect.js +38 -0
  86. package/dist/signInSchemas.d.ts +88 -0
  87. package/dist/signInSchemas.js +71 -0
  88. package/dist/telemetry.d.ts +39 -0
  89. package/dist/telemetry.js +26 -0
  90. package/dist/testing.d.ts +7 -0
  91. package/dist/testing.js +3 -0
  92. package/dist/turnstile.d.ts +14 -0
  93. package/dist/turnstile.js +17 -0
  94. package/dist/turnstileScript.d.ts +18 -0
  95. package/dist/turnstileScript.js +6 -0
  96. package/dist/web/crossTabRefresh.d.ts +18 -0
  97. package/dist/web/crossTabRefresh.js +121 -0
  98. package/dist/webviewDetection.d.ts +5 -0
  99. package/dist/webviewDetection.js +62 -0
  100. package/package.json +114 -0
@@ -0,0 +1,312 @@
1
+ import { DEFAULT_FRESH_MARGIN_MS, createCredentialCache, decodeAdopted, isCredentialFresh, selectFreshCredential } from './credentialCache.js';
2
+ import { isAccountIdentity } from './identity.js';
3
+ import { abortable, exchangeToken } from './exchange.js';
4
+ import { createMintCoordinator } from './mintCoordinator.js';
5
+ import { createRefreshScheduler } from './refreshScheduler.js';
6
+ import { arbitrateMint, initialSessionState, scheduledMintHolds, transition } from './sessionState.js';
7
+ export { isPermanentSessionError } from './sessionContracts.js';
8
+ export { isCredentialFresh } from './credentialCache.js';
9
+ /**
10
+ * The session error codes, keys only. Hosts own the copy (the cloud app's
11
+ * workspaceAuth.errors.*); the package ships the vocabulary so a host can
12
+ * narrow an arbitrary error code to one it has a line for. Exhaustive by
13
+ * type: a new SessionErrorCode is a compile error until it is listed here.
14
+ */
15
+ export const SESSION_ERROR_CODES = {
16
+ NOT_AUTHENTICATED: true,
17
+ INVALID_FIREBASE_TOKEN: true,
18
+ ACCESS_DENIED: true,
19
+ WORKSPACE_NOT_FOUND: true,
20
+ TOKEN_EXCHANGE_FAILED: true
21
+ };
22
+ export { SESSION_TELEMETRY_EVENT } from '../telemetry.js';
23
+ const DEFAULT_MINT_TIMEOUT_MS = 15_000;
24
+ /**
25
+ * The status→code mapping from `requestToken`: 401/403/404 are permanent
26
+ * failures with their own codes; everything else — 5xx, network failure,
27
+ * abort, unparseable body — collapses to TOKEN_EXCHANGE_FAILED, matching
28
+ * production's default branch.
29
+ */
30
+ export function createSessionClient(clientOptions, identity) {
31
+ const { exchangeUrl, storage, freshMarginMs = DEFAULT_FRESH_MARGIN_MS } = clientOptions;
32
+ let state = initialSessionState();
33
+ let detachCurrent;
34
+ const listeners = new Set();
35
+ const cache = createCredentialCache(storage);
36
+ const mints = createMintCoordinator();
37
+ function getSnapshot() {
38
+ const { user, identitySettled, credential, failure } = state;
39
+ if (!user) {
40
+ return identitySettled
41
+ ? { phase: 'signed-out', user: null, session: undefined }
42
+ : { phase: 'pending', user: null, session: undefined };
43
+ }
44
+ if (credential) {
45
+ return { phase: 'authenticated', user, session: credential };
46
+ }
47
+ if (failure) {
48
+ return { phase: 'error', user, session: undefined, failure };
49
+ }
50
+ return { phase: 'minting', user, session: undefined };
51
+ }
52
+ function publish() {
53
+ const snapshot = getSnapshot();
54
+ listeners.forEach((listener) => listener(snapshot));
55
+ }
56
+ // The exchange echoes the requested workspace on success (a non-member 404s),
57
+ // so a scope mismatch is a backend regression to fail closed on — shared by
58
+ // the direct commit and the scheduled-refresh commit.
59
+ function targetMismatch(session, target) {
60
+ return target !== undefined && session.workspace.id !== target
61
+ ? { status: 'error', code: 'ACCESS_DENIED' }
62
+ : undefined;
63
+ }
64
+ /**
65
+ * The package's thin wrapper over the pure exchange: resolves the host's
66
+ * transport/timeout/clock defaults and sends the personal-workspace body.
67
+ */
68
+ function mint(user, options) {
69
+ const workspaceId = options.workspaceId ?? clientOptions.workspaceId;
70
+ if (workspaceId === '') {
71
+ // An explicit empty id is an invalid selection, not personal; fail
72
+ // closed instead of silently minting a personal-scoped session.
73
+ return Promise.resolve({ status: 'error', code: 'WORKSPACE_NOT_FOUND' });
74
+ }
75
+ return exchangeToken(user, {
76
+ exchangeUrl,
77
+ body: workspaceId ? { workspace_id: workspaceId } : {},
78
+ fetchImpl: options.fetchImpl ?? clientOptions.fetchImpl ?? globalThis.fetch,
79
+ signal: options.signal ?? clientOptions.signal,
80
+ timeoutMs: options.timeoutMs ?? clientOptions.timeoutMs ?? DEFAULT_MINT_TIMEOUT_MS,
81
+ now: () => options.now?.() ?? clientOptions.now?.() ?? Date.now()
82
+ });
83
+ }
84
+ function hostNow() {
85
+ return clientOptions.now?.() ?? Date.now();
86
+ }
87
+ function runEffect(effect, now) {
88
+ switch (effect.type) {
89
+ case 'persist':
90
+ cache.write(effect.session, effect.target);
91
+ return;
92
+ case 'clearStorage':
93
+ cache.clear();
94
+ return;
95
+ case 'stopScheduler':
96
+ scheduler?.stop();
97
+ return;
98
+ case 'armScheduler':
99
+ scheduler?.armAfterCommit(effect.session, now());
100
+ return;
101
+ case 'abandonInFlight':
102
+ mints.abandon();
103
+ return;
104
+ case 'publish':
105
+ publish();
106
+ return;
107
+ }
108
+ }
109
+ function commit(event, now = hostNow) {
110
+ const next = transition(state, event);
111
+ state = next.state;
112
+ for (const effect of next.effects)
113
+ runEffect(effect, now);
114
+ return next;
115
+ }
116
+ function sharedMint(user, options, forced) {
117
+ const target = options.workspaceId ?? clientOptions.workspaceId;
118
+ return mints.dispatch(user.uid, target, forced, () => {
119
+ const { mintSequence } = commit({ type: 'mint-started' }).state;
120
+ return { mintId: mintSequence, response: mint(user, options) };
121
+ });
122
+ }
123
+ function ensureCore(user, options) {
124
+ const now = options.now?.() ?? clientOptions.now?.() ?? Date.now();
125
+ const target = options.workspaceId ?? clientOptions.workspaceId;
126
+ const fresh = selectFreshCredential([
127
+ () => ({
128
+ credential: state.credential,
129
+ target: state.credentialTarget
130
+ }),
131
+ () => cache.read(user.uid)
132
+ ], user.uid, target, now, freshMarginMs);
133
+ if (fresh) {
134
+ return {
135
+ mintId: state.mintSequence,
136
+ response: Promise.resolve({ status: 'ok', session: fresh }),
137
+ joined: false
138
+ };
139
+ }
140
+ return sharedMint(user, options, false);
141
+ }
142
+ function remintCore(user, options) {
143
+ cache.clear();
144
+ return sharedMint(user, options, true);
145
+ }
146
+ const scheduler = clientOptions.refreshScheduler
147
+ ? createRefreshScheduler(clientOptions.refreshScheduler, {
148
+ now: hostNow,
149
+ getCurrentUser: () => state.user,
150
+ getCredential: () => state.credential,
151
+ captureGuards: () => ({
152
+ epoch: state.identityEpoch,
153
+ invalidation: state.invalidationEpoch
154
+ }),
155
+ guardsHold: (guards, user, mintId) => scheduledMintHolds(state, guards, user.uid, mintId),
156
+ mint: (user) => sharedMint(user, { workspaceId: state.credentialTarget }, true),
157
+ commitRefreshed: (session, mintId) => {
158
+ const mismatch = targetMismatch(session, state.credentialTarget);
159
+ if (mismatch) {
160
+ commit({
161
+ type: 'mint-rejected',
162
+ origin: 'scheduler',
163
+ failure: mismatch
164
+ });
165
+ return mismatch;
166
+ }
167
+ commit({
168
+ type: 'mint-committed',
169
+ origin: 'scheduler',
170
+ session,
171
+ mintId
172
+ });
173
+ return undefined;
174
+ },
175
+ commitPermanentFailure: (failure) => {
176
+ commit({ type: 'mint-rejected', origin: 'scheduler', failure });
177
+ },
178
+ commitExpired: (expiring) => state.credential === expiring
179
+ ? commit({ type: 'credential-expired', expiring }).state.failure
180
+ : undefined,
181
+ parseAdopted: decodeAdopted,
182
+ commitAdopted: (session) => {
183
+ commit({ type: 'credential-adopted', session });
184
+ }
185
+ })
186
+ : undefined;
187
+ async function refreshWith(core, requestedUser, options = {}) {
188
+ const user = requestedUser ?? state.user;
189
+ if (!user)
190
+ return undefined;
191
+ const startEpoch = state.identityEpoch;
192
+ const startInvalidation = state.invalidationEpoch;
193
+ const startedSignedOut = state.user === null;
194
+ const requestedTarget = options.workspaceId ?? clientOptions.workspaceId;
195
+ const { mintId, response, joined } = core(user, options);
196
+ // A caller that joined an in-flight mint still gets its own signal
197
+ // honored: the shared mint runs on, this caller stops waiting for it.
198
+ let result;
199
+ try {
200
+ result = options.signal
201
+ ? await abortable(response, options.signal)
202
+ : await response;
203
+ }
204
+ catch {
205
+ return { status: 'error', code: 'TOKEN_EXCHANGE_FAILED' };
206
+ }
207
+ const arbitration = arbitrateMint(state, {
208
+ mintId,
209
+ joined,
210
+ explicitUser: requestedUser !== undefined,
211
+ userUid: user.uid,
212
+ requestedTarget,
213
+ startEpoch,
214
+ startInvalidation,
215
+ startedSignedOut
216
+ });
217
+ if (arbitration.verdict === 'superseded')
218
+ return undefined;
219
+ if (arbitration.verdict === 'reuse') {
220
+ return { status: 'ok', session: arbitration.session };
221
+ }
222
+ return commitCallerMint(result, mintId, requestedTarget, options);
223
+ }
224
+ function commitCallerMint(result, mintId, requestedTarget, options) {
225
+ const rejectMint = (failure) => {
226
+ commit({
227
+ type: 'mint-rejected',
228
+ origin: 'caller',
229
+ failure,
230
+ preserveCredentialOnTransientFailure: options.preserveCredentialOnTransientFailure === true
231
+ });
232
+ return failure;
233
+ };
234
+ if (result.status !== 'ok')
235
+ return rejectMint(result);
236
+ const mismatch = targetMismatch(result.session, requestedTarget);
237
+ if (mismatch)
238
+ return rejectMint(mismatch);
239
+ commit({
240
+ type: 'mint-committed',
241
+ origin: 'caller',
242
+ session: result.session,
243
+ target: requestedTarget,
244
+ mintId
245
+ }, () => options.now?.() ?? hostNow());
246
+ return result;
247
+ }
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)
264
+ return;
265
+ commit({ type: 'identity-changed', user: next });
266
+ if (detachCurrent === detach && next && autoMint) {
267
+ void refreshWith(ensureCore, next);
268
+ }
269
+ });
270
+ if (detachCurrent !== detach)
271
+ unsubscribe();
272
+ return detach;
273
+ }
274
+ if (identity)
275
+ subscribeIdentity(identity, clientOptions.autoMint !== false);
276
+ return {
277
+ attachIdentity: (port, attachOptions) => subscribeIdentity(port, attachOptions?.autoMint !== false),
278
+ dispose() {
279
+ detachCurrent?.();
280
+ },
281
+ getSnapshot,
282
+ subscribe(listener) {
283
+ listeners.add(listener);
284
+ // The immediate replay runs host code; if it throws, drop the listener
285
+ // so a failed subscribe leaves nothing behind to publish to.
286
+ try {
287
+ listener(getSnapshot());
288
+ }
289
+ catch (error) {
290
+ listeners.delete(listener);
291
+ throw error;
292
+ }
293
+ return () => listeners.delete(listener);
294
+ },
295
+ getToken() {
296
+ const { user, credential } = state;
297
+ return credential !== undefined &&
298
+ user?.uid === credential.uid &&
299
+ isCredentialFresh(credential, hostNow(), 0)
300
+ ? credential.token
301
+ : undefined;
302
+ },
303
+ ensureFresh: (requestedUser, options) => refreshWith(ensureCore, requestedUser, options),
304
+ remint: (requestedUser, options) => refreshWith(remintCore, requestedUser, options),
305
+ invalidate() {
306
+ commit({ type: 'invalidated' });
307
+ },
308
+ clearStoredCredential() {
309
+ cache.clear();
310
+ }
311
+ };
312
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The contracts the session client shares with the pieces it composes
3
+ * (`exchange`, `refreshScheduler`). Dependency-neutral by design: `session.ts`
4
+ * depends on those pieces, and they depend only on this module, never back on
5
+ * the client — so no top-level binding is ever read before it initializes.
6
+ */
7
+ import type { zExchangeTokenResponse } from '@comfyorg/ingest-types/zod';
8
+ import type { z } from 'zod';
9
+ export interface AccountUser {
10
+ readonly uid: string;
11
+ getIdToken: () => Promise<string>;
12
+ }
13
+ /** Field types come from the generated POST /api/auth/token contract. */
14
+ type ExchangeTokenResponse = z.infer<typeof zExchangeTokenResponse>;
15
+ export interface AccountCredential {
16
+ readonly token: string;
17
+ /** ms since epoch */
18
+ readonly expiresAt: number;
19
+ readonly uid: string;
20
+ readonly workspace: ExchangeTokenResponse['workspace'];
21
+ readonly role: ExchangeTokenResponse['role'];
22
+ readonly permissions: readonly string[];
23
+ }
24
+ /**
25
+ * The production store's error taxonomy (`WorkspaceAuthError` codes),
26
+ * emitted as codes only — the host localizes. TOKEN_EXCHANGE_FAILED also
27
+ * covers unparseable bodies, network failures, aborts, and timeouts: a
28
+ * request that produced no usable credential is one failure bucket in
29
+ * production, not three.
30
+ */
31
+ export type SessionErrorCode = 'NOT_AUTHENTICATED' | 'INVALID_FIREBASE_TOKEN' | 'ACCESS_DENIED' | 'WORKSPACE_NOT_FOUND' | 'TOKEN_EXCHANGE_FAILED';
32
+ export declare function isPermanentSessionError(code: SessionErrorCode): boolean;
33
+ export type SessionResult = {
34
+ readonly status: 'ok';
35
+ readonly session: AccountCredential;
36
+ } | {
37
+ readonly status: 'error';
38
+ readonly code: SessionErrorCode;
39
+ /** Set only when the failure came from an HTTP response, not aborted/network. */
40
+ readonly httpStatus?: number;
41
+ };
42
+ export type SessionFailure = Extract<SessionResult, {
43
+ status: 'error';
44
+ }>;
45
+ /**
46
+ * The result of one SCHEDULED refresh attempt as a single tagged value: the
47
+ * outcomes that committed a failure carry it and the rest structurally cannot,
48
+ * so a permanent failure without its error — or a success with one — cannot be
49
+ * represented.
50
+ *
51
+ * `failure?: never` is what makes "structurally cannot" true rather than
52
+ * aspirational. Without it, excess-property checking still rejects a bad
53
+ * object literal, but a report built in a variable or returned from a helper
54
+ * assigns cleanly.
55
+ */
56
+ export type ScheduledRefreshReport = {
57
+ readonly outcome: 'succeeded';
58
+ readonly failure?: never;
59
+ } | {
60
+ readonly outcome: 'retry_scheduled';
61
+ readonly failure?: never;
62
+ } | {
63
+ readonly outcome: 'retries_exhausted';
64
+ readonly failure?: never;
65
+ } | {
66
+ readonly outcome: 'permanent_failure';
67
+ readonly failure: SessionFailure;
68
+ }
69
+ /** Retries ran out and the credential reached expiry; the client failed closed. */
70
+ | {
71
+ readonly outcome: 'expired';
72
+ readonly failure: SessionFailure;
73
+ };
74
+ /** The outcome discriminants a scheduled refresh can report. */
75
+ export type SessionRefreshOutcome = ScheduledRefreshReport['outcome'];
76
+ export interface MintHandle {
77
+ readonly mintId: number;
78
+ readonly response: Promise<SessionResult>;
79
+ }
80
+ /**
81
+ * Cross-tab refresh coordination. One tab per (uid, workspace) key holds the
82
+ * lease and performs the proactive refresh; the others adopt its published
83
+ * credential and mint for themselves only when the leader goes quiet past
84
+ * their jittered fallback. Real hosts wrap Web Locks + BroadcastChannel
85
+ * (`createWebCrossTabRefreshPort` from `@comfyorg/account-core/web`); tests pass
86
+ * fakes.
87
+ */
88
+ export interface CrossTabRefreshPort {
89
+ /**
90
+ * Queue for the key's lease. `onAcquired` fires if and when this tab
91
+ * becomes leader; the returned function abandons the request or releases
92
+ * held leadership.
93
+ */
94
+ requestLeadership: (key: string, onAcquired: () => void) => () => void;
95
+ publishCredential: (key: string, credential: AccountCredential) => void;
96
+ /** Messages cross a serialization boundary; the client validates them. */
97
+ onCredential: (key: string, callback: (message: unknown) => void) => () => void;
98
+ }
99
+ /**
100
+ * Opt-in proactive refresh, mirroring the cloud store's scheduled-refresh
101
+ * semantics (its buffer, retry base, and retry cap are the defaults): arm at
102
+ * expiry minus the buffer, retry transient failures with doubling backoff,
103
+ * stop on sign-out, detach, or a permanent failure. Hosts whose consumers
104
+ * read the token synchronously need this; valid-on-read hosts do not.
105
+ */
106
+ export interface RefreshSchedulerOptions {
107
+ readonly bufferMs?: number;
108
+ readonly retryBaseMs?: number;
109
+ readonly maxRetries?: number;
110
+ /**
111
+ * Cross-tab coordination (opt-in): the leader tab refreshes and publishes;
112
+ * followers adopt the published credential and fall back to their own mint
113
+ * only after a bounded random hold past the refresh point.
114
+ */
115
+ readonly crossTab?: {
116
+ readonly port: CrossTabRefreshPort;
117
+ /** Upper bound for the follower's random hold. Default 15s. */
118
+ readonly followerJitterMs?: number;
119
+ /**
120
+ * Fires when this tab commits a sibling's credential. Adoption is a
121
+ * rotation the tab did not perform itself, so a host that reacts to
122
+ * rotations (cookie refresh, extension hooks) needs this signal.
123
+ */
124
+ readonly onCredentialAdopted?: (credential: AccountCredential) => void;
125
+ };
126
+ /**
127
+ * Called with the result of every SCHEDULED refresh attempt (never a
128
+ * login or caller-initiated mint), so a host can feed its refresh
129
+ * telemetry without owning the scheduler. A permanent failure and an
130
+ * expiry carry the failure the client committed, so the host never has
131
+ * to read it back out of the snapshot.
132
+ */
133
+ readonly onScheduledOutcome?: (report: ScheduledRefreshReport) => void;
134
+ }
135
+ export {};
@@ -0,0 +1,9 @@
1
+ const PERMANENT_ERROR_CODES = new Set([
2
+ 'ACCESS_DENIED',
3
+ 'WORKSPACE_NOT_FOUND',
4
+ 'INVALID_FIREBASE_TOKEN',
5
+ 'NOT_AUTHENTICATED'
6
+ ]);
7
+ export function isPermanentSessionError(code) {
8
+ return PERMANENT_ERROR_CODES.has(code);
9
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The session's authoritative state and its only commit boundary. Effects
3
+ * come back in the order the client must run them: persist before arming
4
+ * the scheduler, publish last, so a host reacting to a snapshot never reads
5
+ * state a later step would still change.
6
+ */
7
+ import type { AccountCredential, AccountUser, SessionFailure } from './sessionContracts.js';
8
+ export interface SessionState<TUser extends AccountUser = AccountUser> {
9
+ readonly user: TUser | null;
10
+ readonly identitySettled: boolean;
11
+ readonly credential: AccountCredential | undefined;
12
+ readonly credentialTarget: string | undefined;
13
+ readonly failure: SessionFailure | undefined;
14
+ /**
15
+ * The result the winning mint published, so a caller joining that same mint
16
+ * returns it instead of running the commit/publication path a second time.
17
+ */
18
+ readonly committedMint: {
19
+ readonly mintId: number;
20
+ readonly session: AccountCredential;
21
+ } | undefined;
22
+ /**
23
+ * Bumped on every identity event so a mint can tell "the listener has not
24
+ * settled yet" (the legitimate popup path) from "an identity event
25
+ * happened while I was in flight" (must invalidate).
26
+ */
27
+ readonly identityEpoch: number;
28
+ readonly invalidationEpoch: number;
29
+ /**
30
+ * Monotonic id taken by every started mint; a commit is allowed only for
31
+ * the newest one. Target-agnostic on purpose — a slower mint for the old
32
+ * workspace resolving after a switch must never revert it. Ports the
33
+ * cloud store's unifiedRefreshRequestId guard.
34
+ */
35
+ readonly mintSequence: number;
36
+ }
37
+ export declare function initialSessionState<TUser extends AccountUser = AccountUser>(): SessionState<TUser>;
38
+ export type SessionEvent<TUser extends AccountUser = AccountUser> = {
39
+ readonly type: 'identity-changed';
40
+ readonly user: TUser | null;
41
+ } | {
42
+ readonly type: 'identity-detached';
43
+ } | {
44
+ readonly type: 'invalidated';
45
+ } | {
46
+ readonly type: 'mint-started';
47
+ } | {
48
+ readonly type: 'mint-committed';
49
+ readonly origin: 'caller';
50
+ readonly session: AccountCredential;
51
+ readonly target: string | undefined;
52
+ readonly mintId: number;
53
+ } | {
54
+ readonly type: 'mint-committed';
55
+ readonly origin: 'scheduler';
56
+ readonly session: AccountCredential;
57
+ readonly mintId: number;
58
+ } | {
59
+ readonly type: 'mint-rejected';
60
+ readonly origin: 'caller';
61
+ readonly failure: SessionFailure;
62
+ readonly preserveCredentialOnTransientFailure: boolean;
63
+ } | {
64
+ readonly type: 'mint-rejected';
65
+ readonly origin: 'scheduler';
66
+ readonly failure: SessionFailure;
67
+ } | {
68
+ readonly type: 'credential-adopted';
69
+ readonly session: AccountCredential;
70
+ } | {
71
+ readonly type: 'credential-expired';
72
+ readonly expiring: AccountCredential;
73
+ };
74
+ export type SessionEffect = {
75
+ readonly type: 'persist';
76
+ readonly session: AccountCredential;
77
+ readonly target: string | undefined;
78
+ } | {
79
+ readonly type: 'armScheduler';
80
+ readonly session: AccountCredential;
81
+ } | {
82
+ readonly type: 'clearStorage';
83
+ } | {
84
+ readonly type: 'stopScheduler';
85
+ } | {
86
+ readonly type: 'abandonInFlight';
87
+ } | {
88
+ readonly type: 'publish';
89
+ };
90
+ export interface SessionTransition<TUser extends AccountUser = AccountUser> {
91
+ readonly state: SessionState<TUser>;
92
+ readonly effects: readonly SessionEffect[];
93
+ }
94
+ export declare function transition<TUser extends AccountUser>(state: SessionState<TUser>, event: SessionEvent<TUser>): SessionTransition<TUser>;
95
+ export interface MintAttempt {
96
+ readonly mintId: number;
97
+ /** Awaited another owner's in-flight mint instead of starting its own. */
98
+ readonly joined: boolean;
99
+ readonly explicitUser: boolean;
100
+ readonly userUid: string;
101
+ readonly requestedTarget: string | undefined;
102
+ readonly startEpoch: number;
103
+ readonly startInvalidation: number;
104
+ readonly startedSignedOut: boolean;
105
+ }
106
+ export type MintVerdict = {
107
+ readonly verdict: 'commit';
108
+ } | {
109
+ readonly verdict: 'reuse';
110
+ readonly session: AccountCredential;
111
+ } | {
112
+ readonly verdict: 'superseded';
113
+ };
114
+ export declare function arbitrateMint<TUser extends AccountUser>(state: SessionState<TUser>, attempt: MintAttempt): MintVerdict;
115
+ export declare function scheduledMintHolds<TUser extends AccountUser>(state: SessionState<TUser>, guards: {
116
+ readonly epoch: number;
117
+ readonly invalidation: number;
118
+ }, userUid: string, mintId: number): boolean;