@comfyorg/account-core 1.0.0-alpha.0 → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) 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 +225 -19
  17. package/dist/core/billing/operationState.js +93 -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 +63 -9
  26. package/dist/core/billing/subscriptionCommands.js +46 -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/requestTimeout.d.ts +7 -0
  40. package/dist/core/requestTimeout.js +13 -0
  41. package/dist/core/session.d.ts +12 -18
  42. package/dist/core/session.js +16 -26
  43. package/dist/core/sessionContracts.d.ts +39 -2
  44. package/dist/core/sessionTokenMint.d.ts +35 -0
  45. package/dist/core/sessionTokenMint.js +222 -0
  46. package/dist/core/webSession.d.ts +27 -0
  47. package/dist/core/webSession.js +144 -0
  48. package/dist/core/webSessionFlag.d.ts +19 -0
  49. package/dist/core/webSessionFlag.js +61 -0
  50. package/dist/core/webSessionIdentity.d.ts +297 -0
  51. package/dist/core/webSessionIdentity.js +509 -0
  52. package/dist/firebase/configSource.d.ts +54 -0
  53. package/dist/firebase/configSource.js +111 -0
  54. package/dist/firebase/index.d.ts +71 -8
  55. package/dist/firebase/index.js +236 -23
  56. package/dist/firebase/popupWatch.d.ts +42 -0
  57. package/dist/firebase/popupWatch.js +140 -0
  58. package/dist/testing.d.ts +38 -0
  59. package/dist/testing.js +116 -0
  60. package/dist/web/crossTabRefresh.d.ts +4 -2
  61. package/dist/web/crossTabRefresh.js +13 -0
  62. package/dist/workspaceLink.d.ts +45 -0
  63. package/dist/workspaceLink.js +78 -0
  64. package/package.json +30 -6
@@ -16,14 +16,17 @@
16
16
  * wiring onto the scope source, the scope tracker, and the generated
17
17
  * contract is new.
18
18
  */
19
- import { zBillingOpStatusResponse } from '@comfyorg/ingest-types/zod';
20
19
  import { BILLING_OPERATION_TELEMETRY_EVENT } from '../../telemetry.js';
21
20
  import { createBillingScopeTracker } from './billingScope.js';
22
21
  import { NO_POINTER_STORE, createOperationPointerStore } from './operationPointer.js';
23
- import { hasExhaustedPollBudget, nextPollDelayMs } from './operationPolicy.js';
24
- import { isTerminal, reduceBillingOperation, validateActionUrl } from './operationState.js';
22
+ import { hasExhaustedPollBudget, isWaitingOnCustomerWithoutAction, nextPollDelayMs } from './operationPolicy.js';
23
+ import { BillingOpStatusSchema, isGrantLanding, isTerminal, reduceBillingOperation, validateActionUrl } from './operationState.js';
25
24
  import { selectBillingPresentation } from './presentation.js';
26
25
  import { readValidatedBillingResponse } from './sharedRead.js';
26
+ /** Settled, but a later read may still change what the operation reports. */
27
+ function isStillSettling(state) {
28
+ return state.phase === 'reconciliation_needed' || isGrantLanding(state);
29
+ }
27
30
  export function operationRoute(operationId) {
28
31
  return `/billing/ops/${encodeURIComponent(operationId)}`;
29
32
  }
@@ -35,6 +38,10 @@ const SUPERSEDED = {
35
38
  status: 'error',
36
39
  code: 'SUPERSEDED'
37
40
  };
41
+ const OPERATION_ALREADY_PENDING = {
42
+ status: 'error',
43
+ code: 'OPERATION_ALREADY_PENDING'
44
+ };
38
45
  function continuationOf(issued) {
39
46
  return {
40
47
  ...(issued.actionUrl === undefined ? {} : { actionUrl: issued.actionUrl }),
@@ -67,6 +74,7 @@ function initialPendingState(input, observedAt, presentation) {
67
74
  ...presentation,
68
75
  observedAt,
69
76
  attemptStartedAt: input.attemptStartedAt,
77
+ ...(input.awaitedHere ? { awaitedHere: true } : {}),
70
78
  phase: 'pending',
71
79
  ...(actionUrl === undefined ? {} : { actionUrl }),
72
80
  ...(challenge === undefined ? {} : { challenge }),
@@ -96,7 +104,9 @@ export function createBillingOperationLifecycle(options) {
96
104
  const { transport, scopeSource, statusReader, embeddedCheckoutAvailable = () => false, hostedDestination = () => 'stripe', onTelemetry, now = Date.now } = options;
97
105
  const pointers = options.pointerStorage === undefined
98
106
  ? NO_POINTER_STORE
99
- : createOperationPointerStore(options.pointerStorage, now);
107
+ : createOperationPointerStore(options.pointerStorage, now, {
108
+ retainSettled: options.retainSettledPointer === true
109
+ });
100
110
  const operations = new Map();
101
111
  const inFlightCommands = new Map();
102
112
  const listeners = new Set();
@@ -114,7 +124,7 @@ export function createBillingOperationLifecycle(options) {
114
124
  return !lifetime.disposed && scopeTracker.isCurrent(context);
115
125
  }
116
126
  function publish(record) {
117
- for (const listener of [...listeners])
127
+ for (const listener of Array.from(listeners))
118
128
  listener(record.state);
119
129
  }
120
130
  function stopTimer(record) {
@@ -152,7 +162,7 @@ export function createBillingOperationLifecycle(options) {
152
162
  record.state = next;
153
163
  if (isTerminal(next)) {
154
164
  stopTimer(record);
155
- pointers.clearIfTerminal(next);
165
+ pointers.settle(next);
156
166
  emitTerminalTelemetry(record);
157
167
  record.resolveSettled(next);
158
168
  }
@@ -162,7 +172,12 @@ export function createBillingOperationLifecycle(options) {
162
172
  if (record.state.phase !== 'pending')
163
173
  return;
164
174
  stopTimer(record);
165
- const delayMs = nextPollDelayMs(record.state, record.delayMs);
175
+ record.waitingWithoutActionSince = isWaitingOnCustomerWithoutAction(record.state)
176
+ ? (record.waitingWithoutActionSince ?? now())
177
+ : undefined;
178
+ const delayMs = nextPollDelayMs(record.state, record.delayMs, record.waitingWithoutActionSince === undefined
179
+ ? 0
180
+ : now() - record.waitingWithoutActionSince);
166
181
  record.delayMs = delayMs;
167
182
  record.timer = setTimeout(() => void poll(record), delayMs);
168
183
  }
@@ -185,11 +200,12 @@ export function createBillingOperationLifecycle(options) {
185
200
  operationId: state.id,
186
201
  kind: state.kind,
187
202
  presentation: state.presentation,
188
- attemptStartedAt: state.attemptStartedAt
203
+ attemptStartedAt: state.attemptStartedAt,
204
+ ...(state.awaitedHere ? { awaited: true } : {})
189
205
  });
190
206
  }
191
207
  function readOperation(operationId) {
192
- return readValidatedBillingResponse(transport, { method: 'GET', route: operationRoute(operationId) }, (body) => zBillingOpStatusResponse.safeParse(body));
208
+ return readValidatedBillingResponse(transport, { method: 'GET', route: operationRoute(operationId) }, (body) => BillingOpStatusSchema.safeParse(body));
193
209
  }
194
210
  function continueOrExpire(record) {
195
211
  if (record.state.phase !== 'pending')
@@ -248,11 +264,19 @@ export function createBillingOperationLifecycle(options) {
248
264
  ? { presentation, hostedDestination: hostedDestination() }
249
265
  : { presentation };
250
266
  }
251
- function adopt(input) {
267
+ /**
268
+ * An id is observed afresh once this tab has stopped learning anything new
269
+ * about it: its poll budget ran out or it left the scope. One the server
270
+ * parked for reconciliation, or a success whose credits were still landing,
271
+ * is observed afresh only for a caller reading settled operations, since
272
+ * the server may since have settled it or recorded the grant.
273
+ */
274
+ function adopt(input, includeSettled = false) {
252
275
  const existing = operations.get(input.id);
253
276
  if (existing !== undefined &&
254
277
  existing.state.phase !== 'timed_out' &&
255
- existing.state.phase !== 'superseded') {
278
+ existing.state.phase !== 'superseded' &&
279
+ !(includeSettled && isStillSettling(existing.state))) {
256
280
  return existing;
257
281
  }
258
282
  const state = initialPendingState(input, now(), destinationFor(input.presentation));
@@ -265,6 +289,7 @@ export function createBillingOperationLifecycle(options) {
265
289
  context: input.context,
266
290
  resumed: input.resumed,
267
291
  delayMs: undefined,
292
+ waitingWithoutActionSince: undefined,
268
293
  timer: undefined,
269
294
  inFlightPoll: undefined,
270
295
  settled,
@@ -279,15 +304,22 @@ export function createBillingOperationLifecycle(options) {
279
304
  presentation: input.presentation,
280
305
  resumed: input.resumed
281
306
  });
282
- publish(record);
283
- if (input.initialStatus === undefined) {
284
- void poll(record);
285
- }
286
- else {
307
+ startObserving(record, input, state);
308
+ return record;
309
+ }
310
+ // A resumed operation without a served link is announced by its first
311
+ // status, never before it: the status read names it but not what it waits
312
+ // on, and a checkout parked on a card must not be announced as processing
313
+ // for the length of a poll. A served link already says what it waits on.
314
+ function startObserving(record, input, state) {
315
+ if (input.initialStatus !== undefined) {
287
316
  dispatch(record, { type: 'status_polled', status: input.initialStatus });
288
317
  continueOrExpire(record);
318
+ return;
289
319
  }
290
- return record;
320
+ if (!input.resumed || state.actionUrl !== undefined)
321
+ publish(record);
322
+ void poll(record);
291
323
  }
292
324
  function routeFor(rail, issued) {
293
325
  return selectBillingPresentation({
@@ -311,34 +343,21 @@ export function createBillingOperationLifecycle(options) {
311
343
  if (status.status === 'error')
312
344
  return status;
313
345
  const rail = status.value.status.billing_rail;
314
- const pending = pendingFromStatus(status.value.status);
315
- if (pending !== undefined && pending.kind === kind) {
316
- const record = adopt({
317
- ...pending,
318
- context,
319
- presentation: routeFor(rail, pending),
320
- attemptStartedAt: now(),
321
- resumed: true
322
- });
323
- return { status: 'ok', value: record.state };
324
- }
346
+ const parked = await resubmitTarget(pendingFromStatus(status.value.status), kind);
347
+ if (!isLive(context))
348
+ return SUPERSEDED;
349
+ if (parked === 'refused')
350
+ return OPERATION_ALREADY_PENDING;
325
351
  const attemptStartedAt = now();
326
352
  const issued = await issue(context.scope);
327
353
  if (!isLive(context)) {
328
- // The operation exists server-side under the scope this tab just left;
329
- // the pointer waits there so a return recovers it rather than reissuing.
330
- if (issued.status === 'ok') {
331
- pointers.write(context.scope, {
332
- operationId: issued.value.operationId,
333
- kind,
334
- presentation: routeFor(rail, issued.value),
335
- attemptStartedAt
336
- });
337
- }
338
- return SUPERSEDED;
354
+ return leftScope(context, kind, rail, issued, attemptStartedAt);
339
355
  }
340
356
  if (issued.status === 'error')
341
357
  return issued;
358
+ const resumed = parked === undefined ? undefined : resumeParked(parked, issued.value);
359
+ if (resumed !== undefined)
360
+ return { status: 'ok', value: resumed };
342
361
  const record = adopt({
343
362
  id: issued.value.operationId,
344
363
  kind,
@@ -346,10 +365,66 @@ export function createBillingOperationLifecycle(options) {
346
365
  presentation: routeFor(rail, issued.value),
347
366
  ...continuationOf(issued.value),
348
367
  attemptStartedAt,
349
- resumed: false
368
+ resumed: false,
369
+ awaitedHere: true
350
370
  });
351
371
  return { status: 'ok', value: record.state };
352
372
  }
373
+ // The operation exists server-side under the scope this tab just left; the
374
+ // pointer waits there so a return recovers it rather than reissuing.
375
+ function leftScope(context, kind, rail, issued, attemptStartedAt) {
376
+ if (issued.status === 'ok') {
377
+ pointers.write(context.scope, {
378
+ operationId: issued.value.operationId,
379
+ kind,
380
+ presentation: routeFor(rail, issued.value),
381
+ attemptStartedAt,
382
+ awaited: true
383
+ });
384
+ }
385
+ return SUPERSEDED;
386
+ }
387
+ /**
388
+ * Declines rather than joins an operation of this kind the server already
389
+ * has pending, because the status names no plan: this caller asked for one
390
+ * outcome and the parked attempt settles another, so reporting that one as
391
+ * this command's result would tell the customer they bought something they
392
+ * did not choose. recover() is where a deliberate return to it belongs.
393
+ *
394
+ * The exception is a checkout this tab watches parked on a card. The server
395
+ * keeps no link back to it, and a resubmit is how it resumes that checkout
396
+ * or replaces it, so the command goes through with that record in hand.
397
+ */
398
+ async function resubmitTarget(pending, kind) {
399
+ if (pending?.kind !== kind)
400
+ return undefined;
401
+ const record = operations.get(pending.id);
402
+ if (record?.state.phase === 'pending' &&
403
+ record.state.serverPhase === undefined) {
404
+ const read = await readOperation(pending.id);
405
+ if (read.status === 'ok') {
406
+ dispatch(record, { type: 'status_polled', status: read.value.data });
407
+ }
408
+ }
409
+ return record?.state.phase === 'pending' &&
410
+ record.state.serverPhase === 'awaiting_payment_method'
411
+ ? record
412
+ : 'refused';
413
+ }
414
+ /**
415
+ * The same id is the parked checkout resumed, with the fresh hosted step the
416
+ * server minted for it; another id replaced it, so this tab stops watching.
417
+ */
418
+ function resumeParked(parked, issued) {
419
+ if (parked.state.id !== issued.operationId) {
420
+ dispatch(parked, { type: 'superseded' });
421
+ return undefined;
422
+ }
423
+ if (issued.actionUrl !== undefined) {
424
+ dispatch(parked, { type: 'action_reissued', actionUrl: issued.actionUrl });
425
+ }
426
+ return parked.state;
427
+ }
353
428
  function begin(kind, issue) {
354
429
  if (lifetime.disposed)
355
430
  return Promise.resolve(SUPERSEDED);
@@ -370,38 +445,43 @@ export function createBillingOperationLifecycle(options) {
370
445
  inFlightCommands.set(kind, { context, promise: attempt });
371
446
  return attempt;
372
447
  }
373
- // Unreachable is not "nothing pending": the pointer is the only evidence
374
- // left, and observing it costs a poll while reissuing could cost a charge.
375
- function recoverFromPointer(failure, pointer, context) {
376
- if (failure.code !== 'REQUEST_FAILED' || pointer === undefined) {
377
- return failure;
378
- }
379
- const record = adopt({
448
+ function fromPointer(pointer, context) {
449
+ return {
380
450
  id: pointer.operationId,
381
451
  kind: pointer.kind,
382
452
  context,
383
453
  presentation: pointer.presentation,
384
454
  attemptStartedAt: pointer.attemptStartedAt,
385
- resumed: true
386
- });
455
+ resumed: true,
456
+ awaitedHere: pointer.awaited === true
457
+ };
458
+ }
459
+ // Unreachable is not "nothing pending": the pointer is the only evidence
460
+ // left, and observing it costs a poll while reissuing could cost a charge.
461
+ function recoverFromPointer(failure, pointer, context, includeSettled) {
462
+ if (failure.code !== 'REQUEST_FAILED' || pointer === undefined) {
463
+ return failure;
464
+ }
465
+ const record = adopt(fromPointer(pointer, context), includeSettled);
387
466
  return { status: 'ok', value: record.state };
388
467
  }
389
- function resumeServerPending(pending, rail, pointer, context) {
468
+ function resumeServerPending(pending, rail, pointer, context, includeSettled) {
390
469
  const known = pointer?.operationId === pending.id ? pointer : undefined;
391
470
  const record = adopt({
392
471
  ...pending,
393
472
  context,
394
473
  presentation: known?.presentation ?? routeFor(rail, pending),
395
474
  attemptStartedAt: known?.attemptStartedAt ?? now(),
396
- resumed: true
397
- });
475
+ resumed: true,
476
+ awaitedHere: known?.awaited === true
477
+ }, includeSettled);
398
478
  return { status: 'ok', value: record.state };
399
479
  }
400
480
  // The server reports nothing pending, so the pointed-at operation has
401
481
  // either settled since this tab last saw it or never belonged to this
402
482
  // scope. One read decides which; a stale pointer is dropped, never
403
483
  // re-observed on a schedule.
404
- async function probePointer(pointer, context) {
484
+ async function probePointer(pointer, context, includeSettled) {
405
485
  const probe = await readOperation(pointer.operationId);
406
486
  if (!isLive(context))
407
487
  return SUPERSEDED;
@@ -411,18 +491,17 @@ export function createBillingOperationLifecycle(options) {
411
491
  pointers.clear(context.scope, pointer.operationId);
412
492
  return { status: 'ok', value: undefined };
413
493
  }
414
- const record = adopt({
415
- id: pointer.operationId,
416
- kind: pointer.kind,
417
- context,
418
- presentation: pointer.presentation,
419
- attemptStartedAt: pointer.attemptStartedAt,
420
- resumed: true,
421
- initialStatus: probe.value.data
422
- });
494
+ const record = adopt({ ...fromPointer(pointer, context), initialStatus: probe.value.data }, includeSettled);
423
495
  return { status: 'ok', value: record.state };
424
496
  }
425
- async function recover() {
497
+ /** A settled pointer answers only a caller that asked for it. */
498
+ function readPointer(scope, includeSettled) {
499
+ const pointer = pointers.read(scope);
500
+ return pointer?.settled === undefined || includeSettled
501
+ ? pointer
502
+ : undefined;
503
+ }
504
+ async function recover({ includeSettled = false } = {}) {
426
505
  if (lifetime.disposed)
427
506
  return SUPERSEDED;
428
507
  const context = scopeTracker.capture();
@@ -431,17 +510,17 @@ export function createBillingOperationLifecycle(options) {
431
510
  const status = await statusReader.read();
432
511
  if (!isLive(context))
433
512
  return SUPERSEDED;
434
- const pointer = pointers.read(context.scope);
513
+ const pointer = readPointer(context.scope, includeSettled);
435
514
  if (status.status === 'error') {
436
- return recoverFromPointer(status, pointer, context);
515
+ return recoverFromPointer(status, pointer, context, includeSettled);
437
516
  }
438
517
  const pending = pendingFromStatus(status.value.status);
439
518
  if (pending !== undefined) {
440
- return resumeServerPending(pending, status.value.status.billing_rail, pointer, context);
519
+ return resumeServerPending(pending, status.value.status.billing_rail, pointer, context, includeSettled);
441
520
  }
442
521
  if (pointer === undefined)
443
522
  return { status: 'ok', value: undefined };
444
- return probePointer(pointer, context);
523
+ return probePointer(pointer, context, includeSettled);
445
524
  }
446
525
  function wake() {
447
526
  for (const record of operations.values()) {
@@ -22,16 +22,24 @@ declare const PointerSchema: z.ZodObject<{
22
22
  kind: z.ZodEnum<["subscription", "topup", "cancel"]>;
23
23
  presentation: z.ZodEnum<["embedded", "hosted"]>;
24
24
  attemptStartedAt: z.ZodNumber;
25
+ /** This tab issued the operation and has not yet seen it succeed. */
26
+ awaited: z.ZodOptional<z.ZodLiteral<true>>;
27
+ /** The end this tab saw it reach, kept only by a store that retains settled pointers. */
28
+ settled: z.ZodOptional<z.ZodEnum<["succeeded", "reconciliation_needed"]>>;
25
29
  }, "strip", z.ZodTypeAny, {
26
30
  kind: "cancel" | "subscription" | "topup";
27
31
  attemptStartedAt: number;
28
32
  presentation: "embedded" | "hosted";
29
33
  operationId: string;
34
+ awaited?: true | undefined;
35
+ settled?: "succeeded" | "reconciliation_needed" | undefined;
30
36
  }, {
31
37
  kind: "cancel" | "subscription" | "topup";
32
38
  attemptStartedAt: number;
33
39
  presentation: "embedded" | "hosted";
34
40
  operationId: string;
41
+ awaited?: true | undefined;
42
+ settled?: "succeeded" | "reconciliation_needed" | undefined;
35
43
  }>;
36
44
  export type BillingOperationPointer = z.infer<typeof PointerSchema>;
37
45
  export declare function operationPointerKey(scope: BillingScope): string;
@@ -40,9 +48,26 @@ export interface OperationPointerStore {
40
48
  write: (scope: BillingScope, pointer: BillingOperationPointer) => void;
41
49
  /** Clears only when the stored pointer names `operationId`, or unconditionally without one. */
42
50
  clear: (scope: BillingScope, operationId?: string) => void;
43
- clearIfTerminal: (state: BillingOperationState) => void;
51
+ /**
52
+ * What the operation's phase does to the pointer naming it. A client-side
53
+ * timeout is this tab giving up, not the server finishing: an operation
54
+ * awaiting bank authentication stays pending for hours, so the pointer
55
+ * outlives it. A superseded operation belongs to a scope this tab left, and
56
+ * its pointer waits under that scope's key for a return. A failure clears
57
+ * it, so a reload offers a fresh attempt. A success, or an operation the
58
+ * server parked for reconciliation, clears it too, unless the store retains
59
+ * settled pointers.
60
+ */
61
+ settle: (state: BillingOperationState) => void;
44
62
  }
45
- export declare function createOperationPointerStore(storage: BillingOperationPointerStorage, now?: () => number): OperationPointerStore;
63
+ export interface OperationPointerStoreOptions {
64
+ /**
65
+ * Keep a settled pointer, marked with how it ended, so a page that is the
66
+ * checkout itself can read it back after a reload.
67
+ */
68
+ readonly retainSettled?: boolean;
69
+ }
70
+ export declare function createOperationPointerStore(storage: BillingOperationPointerStorage, now?: () => number, { retainSettled }?: OperationPointerStoreOptions): OperationPointerStore;
46
71
  /** A store for hosts without tab-local storage: nothing is ever recovered. */
47
72
  export declare const NO_POINTER_STORE: OperationPointerStore;
48
73
  export {};
@@ -15,24 +15,12 @@ const PointerSchema = z.object({
15
15
  operationId: z.string().min(1),
16
16
  kind: z.enum(['subscription', 'topup', 'cancel']),
17
17
  presentation: z.enum(['embedded', 'hosted']),
18
- attemptStartedAt: z.number().finite()
18
+ attemptStartedAt: z.number().finite(),
19
+ /** This tab issued the operation and has not yet seen it succeed. */
20
+ awaited: z.literal(true).optional(),
21
+ /** The end this tab saw it reach, kept only by a store that retains settled pointers. */
22
+ settled: z.enum(['succeeded', 'reconciliation_needed']).optional()
19
23
  });
20
- /**
21
- * A client-side timeout is this tab giving up, not the server finishing:
22
- * an operation awaiting bank authentication stays pending for hours, so the
23
- * pointer outlives it. A superseded operation belongs to a scope this tab
24
- * left, and its pointer waits under that scope's key for a return.
25
- * `reconciliation_needed` is terminal for polling, so retaining its pointer
26
- * would re-poll a settled operation on every reload.
27
- */
28
- const CLEARS_POINTER = {
29
- pending: false,
30
- timed_out: false,
31
- superseded: false,
32
- succeeded: true,
33
- failed: true,
34
- reconciliation_needed: true
35
- };
36
24
  export function operationPointerKey(scope) {
37
25
  return `${POINTER_KEY_PREFIX}:${scope.userId}:${scope.workspaceId}`;
38
26
  }
@@ -56,7 +44,14 @@ function decodePointer(raw, now) {
56
44
  ? parsed.data
57
45
  : undefined;
58
46
  }
59
- export function createOperationPointerStore(storage, now = Date.now) {
47
+ /** A success is no longer awaited; an operation still being reconciled is. */
48
+ function settledPointer(pointer, phase) {
49
+ if (phase === 'reconciliation_needed')
50
+ return { ...pointer, settled: phase };
51
+ const { awaited: _seen, ...rest } = pointer;
52
+ return { ...rest, settled: phase };
53
+ }
54
+ export function createOperationPointerStore(storage, now = Date.now, { retainSettled = false } = {}) {
60
55
  const read = (scope) => {
61
56
  const key = operationPointerKey(scope);
62
57
  const pointer = decodePointer(attempt(() => storage.getItem(key), null), now());
@@ -64,26 +59,42 @@ export function createOperationPointerStore(storage, now = Date.now) {
64
59
  attempt(() => storage.removeItem(key), undefined);
65
60
  return pointer;
66
61
  };
62
+ const write = (scope, pointer) => attempt(() => storage.setItem(operationPointerKey(scope), JSON.stringify(pointer)), undefined);
67
63
  const clear = (scope, operationId) => {
68
64
  if (operationId !== undefined && read(scope)?.operationId !== operationId) {
69
65
  return;
70
66
  }
71
67
  attempt(() => storage.removeItem(operationPointerKey(scope)), undefined);
72
68
  };
73
- return {
74
- read,
75
- write: (scope, pointer) => attempt(() => storage.setItem(operationPointerKey(scope), JSON.stringify(pointer)), undefined),
76
- clear,
77
- clearIfTerminal: (state) => {
78
- if (CLEARS_POINTER[state.phase])
69
+ const retain = (state, phase) => {
70
+ const pointer = read(state.scope);
71
+ if (pointer?.operationId !== state.id)
72
+ return;
73
+ write(state.scope, settledPointer(pointer, phase));
74
+ };
75
+ const settle = (state) => {
76
+ switch (state.phase) {
77
+ case 'pending':
78
+ case 'timed_out':
79
+ case 'superseded':
80
+ return;
81
+ case 'failed':
79
82
  clear(state.scope, state.id);
83
+ return;
84
+ case 'succeeded':
85
+ case 'reconciliation_needed':
86
+ if (retainSettled)
87
+ retain(state, state.phase);
88
+ else
89
+ clear(state.scope, state.id);
80
90
  }
81
91
  };
92
+ return { read, write, clear, settle };
82
93
  }
83
94
  /** A store for hosts without tab-local storage: nothing is ever recovered. */
84
95
  export const NO_POINTER_STORE = {
85
96
  read: () => undefined,
86
97
  write: () => { },
87
98
  clear: () => { },
88
- clearIfTerminal: () => { }
99
+ settle: () => { }
89
100
  };
@@ -1,10 +1,12 @@
1
- import type { PendingBillingOperation } from './operationState.js';
1
+ import type { BillingAuthenticationState, EmbeddedChallenge, PendingBillingOperation } from './operationState.js';
2
2
  export declare const OPERATION_POLL_TIMING: {
3
3
  readonly initialMs: 1000;
4
4
  readonly maxMs: 8000;
5
5
  readonly multiplier: 1.5;
6
6
  /** An operation parked on the customer is checked on a slow, flat cadence. */
7
7
  readonly parkedMs: 30000;
8
+ /** Fast backoff for an actionless blocked wait: 20 turns of the 3 s PI status cache. */
9
+ readonly actionDiscoveryMs: 60000;
8
10
  };
9
11
  export declare const OPERATION_POLL_BUDGET: {
10
12
  readonly defaultMs: 120000;
@@ -14,13 +16,48 @@ export declare const OPERATION_POLL_BUDGET: {
14
16
  readonly customerActionMs: number;
15
17
  };
16
18
  /**
17
- * Waiting on the customer, not on the backend: a challenge to complete
18
- * elsewhere, a hosted page to finish, a phase the server reports as blocked on
19
- * them, or a declined attempt awaiting their retry. Once this tab's own
20
- * challenge completes the state reads processing and nothing waits on the
21
- * customer anymore.
19
+ * What one tab holds for the customer on a pending operation. Each rail
20
+ * builds it from its own state, because each renders a different surface:
21
+ * the legacy cloud store offers the hosted link beside the embedded
22
+ * challenge, while the SDK renders exactly one presentation.
23
+ */
24
+ export interface CustomerActionHold {
25
+ readonly authenticationState?: BillingAuthenticationState | null;
26
+ /** A hosted page this tab would open for the customer. */
27
+ readonly offersHostedPage: boolean;
28
+ /** The embedded challenge this tab holds, whatever became of it. */
29
+ readonly challenge?: EmbeddedChallenge['status'];
30
+ }
31
+ /**
32
+ * The one "customer can act here" rule both billing rails park on: a
33
+ * retryable failure to retry, a hosted page to visit, or an embedded
34
+ * challenge the server still says is required. A non-retryable decline never
35
+ * reaches it; the operation is terminal by then.
36
+ */
37
+ export declare function customerCanActHere(hold: CustomerActionHold): boolean;
38
+ /**
39
+ * The SDK renders one presentation: a hosted operation offers only its page,
40
+ * an embedded one only its challenge. The cloud app's
41
+ * `legacyOperationActionHold` names the rows where the two surfaces differ.
42
+ */
43
+ export declare function pendingOperationActionHold(state: PendingBillingOperation): CustomerActionHold;
44
+ /**
45
+ * Parked only while the customer can act here. The server can report a
46
+ * blocked phase and a client secret before its cached `authentication_state`
47
+ * catches up, and the slow cadence would then hold a screen with no action.
22
48
  */
23
49
  export declare function isParkedOnCustomer(state: PendingBillingOperation): boolean;
24
- export declare function nextPollDelayMs(state: PendingBillingOperation, previousDelayMs: number | undefined): number;
50
+ /**
51
+ * Blocked on the customer with nothing to offer them yet: either the action
52
+ * is still on its way, or it belongs to someone else (a member without
53
+ * billing permission, a tab without embedded checkout).
54
+ */
55
+ export declare function isWaitingOnCustomerWithoutAction(state: PendingBillingOperation): boolean;
56
+ /**
57
+ * `waitedWithoutActionMs` is how long the operation has continuously been
58
+ * {@link isWaitingOnCustomerWithoutAction}; past the discovery window it
59
+ * parks too, so an action that never arrives here costs the slow cadence.
60
+ */
61
+ export declare function nextPollDelayMs(state: PendingBillingOperation, previousDelayMs: number | undefined, waitedWithoutActionMs?: number): number;
25
62
  export declare function pollBudgetMs(state: PendingBillingOperation): number;
26
63
  export declare function hasExhaustedPollBudget(state: PendingBillingOperation, now: number): boolean;