@pellux/goodvibes-daemon 1.28.20 → 1.28.21

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * browser-checkout-seam-holder.ts, holding a seam that arrives after its
3
+ * first reader is constructed.
4
+ *
5
+ * ── Why a holder, and not a constructor argument ──────────────────────────
6
+ *
7
+ * The SDK hands out a `BrowserCheckoutSeam` through `onBrowserCheckout`, a
8
+ * callback `composeDaemonBrowser` invokes once, synchronously, at the moment
9
+ * IT composes the browser. In this daemon that composition runs inside
10
+ * `attachWsOnlyGatewayVerbHandlers` (services.ts), which is called AFTER
11
+ * `createDaemonHandlerComposition` builds the payments handlers, because the
12
+ * ws-only verb groups need managers (disposal scope, workspace checkpoint
13
+ * manager, ...) that do not exist yet at the point payments is composed.
14
+ * Reordering the two would ripple across every other verb group that call
15
+ * builds, for one capability's benefit.
16
+ *
17
+ * So the payments composition cannot receive the seam as a constructor
18
+ * argument; it receives a GETTER, closed over this holder, and reads it at
19
+ * CALL time rather than at registration time. By the time any real invocation
20
+ * reaches `payments.checkout.begin`, the daemon has finished booting and
21
+ * `onBrowserCheckout` has already fired (or the composition is one where the
22
+ * browser was never buildable at all, `composeDaemonBrowser` returned null,
23
+ * homeDirectory absent, and the getter honestly keeps returning undefined
24
+ * forever, which the checkout handler already refuses on cleanly).
25
+ */
26
+ import type { BrowserCheckoutSeam } from '@pellux/goodvibes-sdk/platform/control-plane';
27
+
28
+ export interface BrowserCheckoutSeamHolder {
29
+ readonly get: () => BrowserCheckoutSeam | undefined;
30
+ readonly set: (seam: BrowserCheckoutSeam) => void;
31
+ /**
32
+ * Forgets the held seam. Call this from the browser's own disposal path
33
+ * (services.ts, registered on the SAME disposal scope the browser sessions
34
+ * teardown runs on), so a daemon that has started shutting its browser down
35
+ * cannot hand a checkout call a seam whose `driverFor`/`cardFieldGuard` point
36
+ * at an engine that is being (or has been) torn down. After this, `get()`
37
+ * returns `undefined` again, and `payments.checkout.begin`/`.fillCard` fall
38
+ * back to their ordinary "checkout is not available right now" 409 refusal,
39
+ * see checkout-handlers.ts, rather than reaching into a disposed engine.
40
+ */
41
+ readonly clear: () => void;
42
+ }
43
+
44
+ export function createBrowserCheckoutSeamHolder(): BrowserCheckoutSeamHolder {
45
+ let seam: BrowserCheckoutSeam | undefined;
46
+ return {
47
+ get: () => seam,
48
+ set: (received) => {
49
+ seam = received;
50
+ },
51
+ clear: () => {
52
+ seam = undefined;
53
+ },
54
+ };
55
+ }
@@ -20,6 +20,8 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
20
  import type { ClusterCoordinator } from '@pellux/goodvibes-sdk/platform/cluster';
21
21
  import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
22
22
  import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
23
+ import type { ChannelDeliveryRouter } from '@pellux/goodvibes-sdk/platform/channels';
24
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
23
25
  import { registerDaemonHandlers, type DaemonHandlerSurfaces } from '../daemon/handlers/index.ts';
24
26
  import type { HandlerContext, HandlerLogger } from '../daemon/handlers/context.ts';
25
27
  import { createDaemonCredentialStore } from '../daemon/handlers/credentials.ts';
@@ -31,6 +33,7 @@ import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
31
33
  import { createPaymentsServices } from './payments-composition.ts';
32
34
  import { inboxPollerGate } from './cluster-composition.ts';
33
35
  import type { ShellPathService } from '@/runtime/index.ts';
36
+ import type { BrowserCheckoutSeamHolder } from './browser-checkout-seam-holder.ts';
34
37
 
35
38
  export interface DaemonHandlerCompositionOptions {
36
39
  readonly gatewayMethods: GatewayMethodCatalog;
@@ -46,6 +49,15 @@ export interface DaemonHandlerCompositionOptions {
46
49
  * composition root; the poller is never started outside it.
47
50
  */
48
51
  readonly clusterCoordinator: ClusterCoordinator;
52
+ /**
53
+ * Where `payments.checkout.*` reads the browser-checkout seam, filled later
54
+ * by services.ts's own `onBrowserCheckout`. See
55
+ * runtime/browser-checkout-seam-holder.ts and payments-composition.ts's
56
+ * header for why this has to be a getter rather than the seam itself.
57
+ */
58
+ readonly checkoutSeam: BrowserCheckoutSeamHolder['get'];
59
+ readonly channelDeliveryRouter: Pick<ChannelDeliveryRouter, 'deliver'>;
60
+ readonly providerRegistry: Pick<ProviderRegistry, 'getCurrentModel' | 'getForModel'>;
49
61
  }
50
62
 
51
63
  export function createDaemonHandlerComposition(
@@ -77,8 +89,9 @@ export function createDaemonHandlerComposition(
77
89
  registerDrafts: (ctx) => registerDraftMethods(ctx),
78
90
  // The payment stores need a path resolver and a daemon-scoped secret writer,
79
91
  // neither of which is on HandlerContext, so they are built here and the
80
- // provider only carries the teardown. See payments-composition.ts for which
81
- // verbs this attaches and which two it deliberately leaves refusing.
92
+ // provider only carries the teardown. See payments-composition.ts for the
93
+ // full checkout composition (address store, notifier, merchant judge,
94
+ // browser-checkout seam).
82
95
  registerPayments: () => createPaymentsServices({
83
96
  gatewayMethods: options.gatewayMethods,
84
97
  configManager: options.configManager,
@@ -99,17 +112,21 @@ export function createDaemonHandlerComposition(
99
112
  // (inboxPollerGate). It is the right answer once there is something to
100
113
  // elect over, and today there is not: ClusterConsumerGate.start() is
101
114
  // specified to not resolve until consumption has actually begun, and this
102
- // composition has no payments consumer to start, checkout is unattached.
103
- // Registering a gate whose start() does nothing would put a fake consumer
104
- // in the election and in `cluster status`.
115
+ // composition has no payments consumer to start it. Checkout is now
116
+ // wired (payments.checkout.begin/.fillCard), but registering a gate whose
117
+ // start() does nothing would STILL put a fake consumer in the election and
118
+ // in `cluster status`; a real election is a separate piece of work, left
119
+ // for a later pass, not something wiring the checkout pair itself needed.
105
120
  //
106
- // So the honest reading of the topology: clustering off means this is the
107
- // only node, and it is trivially the one that would spend; clustering on
108
- // means no payments election has been held and this node cannot claim to
109
- // have won it. False on every node is also the safe direction, checkPaymentGates
110
- // refuses on false. Wiring payments.checkout.* is what should replace this
111
- // with a real gate and holdsSurface().
121
+ // So the honest reading of the topology stays what it was: clustering off
122
+ // means this is the only node, and it is trivially the one that would
123
+ // spend; clustering on means no payments election has been held and this
124
+ // node cannot claim to have won it. False on every node is also the safe
125
+ // direction, checkPaymentGates refuses on false.
112
126
  isPaymentsLeader: () => !options.clusterCoordinator.enabled,
127
+ checkoutSeam: options.checkoutSeam,
128
+ channelDeliveryRouter: options.channelDeliveryRouter,
129
+ providerRegistry: options.providerRegistry,
113
130
  }).unregister,
114
131
  registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
115
132
  });
@@ -31,44 +31,67 @@
31
31
  * time" for a card;
32
32
  * - the purchase audit ledger beside the card file.
33
33
  *
34
- * ── The budget ledger is in-process, and that is a statement ──────────────
34
+ * ── The budget ledger is durable ──────────────────────────────────────────
35
35
  *
36
- * `BudgetLedger` is constructed empty here and never persisted. That is correct
37
- * ONLY while checkout is unattached: the sole writer of a spend record is the
38
- * checkout flow, so there is nothing to persist, and a state file nothing writes
39
- * would be decoration. It stops being correct the moment `payments.checkout.begin`
40
- * is wired, and the failure would be silent and expensive, a daemon restarted at
41
- * noon would hand back a daily budget it had already spent. Making this ledger
42
- * durable is part of wiring checkout.
36
+ * `BudgetLedger` used to be constructed empty here and never persisted, correct
37
+ * only while checkout stayed unattached (the sole writer of a spend record was
38
+ * the checkout flow, so there was nothing to persist). Now that checkout is
39
+ * wired below, `DurableBudgetLedger`
40
+ * (daemon/handlers/payments/budget-store.ts) is used instead: it loads its
41
+ * state from `payments-budget.json` beside the card and purchase files at
42
+ * construction and writes back after every reservation, commit and release, so
43
+ * a daemon restarted mid-day does not hand back a budget it already spent.
43
44
  *
44
- * ── What is NOT composed ──────────────────────────────────────────────────
45
+ * ── The checkout pair, over the sdk 2.0.19 browser-checkout seam ──────────
45
46
  *
46
- * `payments.checkout.begin` and `payments.checkout.fillCard` stay unattached and
47
- * keep answering 501. They need a `CheckoutPageDriver` over an open browser
48
- * page, and this composition has no way to obtain one: the SDK builds its
49
- * browser engine inside `registerGatewayVerbGroups`
50
- * (control-plane/routes/browser-composition.ts), hands back only the
51
- * `BrowserGatewayService` slice, which exposes no page handle and no
52
- * `fillSecret`, and constructs it with no `cardFieldGuard`, which its own
53
- * secret-fill path refuses without. There is also no adapter anywhere from a
54
- * browser session to a `CheckoutPageDriver`. Writing one here would put the
55
- * card-into-page seam in this repository, and card-material.ts is explicit that
56
- * exactly one module in the platform may produce card material. So the two verbs
57
- * refuse honestly instead.
47
+ * `payments.checkout.begin`/`.fillCard` need a `CardMaterialRedactor` bound to
48
+ * the SAME browser engine the `browser.*` verbs drive, and this daemon does not
49
+ * build that engine, `composeDaemonBrowser` does (control-plane/routes/
50
+ * browser-composition.ts), inside `registerGatewayVerbGroups`, which THIS
51
+ * composition runs before (see runtime/services.ts). `checkoutSeam` is
52
+ * therefore a GETTER, not a value: services.ts passes `onBrowserCheckout` to
53
+ * `attachWsOnlyGatewayVerbHandlers` wired to fill the SAME holder this getter
54
+ * reads (runtime/browser-checkout-seam-holder.ts), and `register.ts`'s checkout
55
+ * handlers read it fresh on every call rather than once at composition time.
56
+ *
57
+ * The rest of `PaymentsGatewayServiceImpl`'s dependencies this composition owns
58
+ * outright: `configBackedAddressStore` reads the shipping/billing addresses the
59
+ * owner profile already writes into `payments.*Address.*` config keys (see
60
+ * that module's header for the exact defect this closes), and
61
+ * `channelBackedPaymentNotifier`/`createProviderBackedMerchantJudgeModel` adapt
62
+ * this daemon's channel router and provider registry to the ports the SDK
63
+ * declares. The untrusted-content ledger is the SAME process-wide singleton the
64
+ * browser composition binds its engine to (`getProcessUntrustedContentLedger`),
65
+ * never a private one, for the reason browser-composition.ts's header gives:
66
+ * a private ledger would make cross-capability derivation invisible.
67
+ *
68
+ * `channelBackedPaymentNotifier`'s own header names the one piece deliberately
69
+ * left for a later pass: no live inbound-reply correlation, so every purchase
70
+ * settles on the windows' own silence rules rather than an early answer. That
71
+ * is a scoped, disclosed gap, not a silent one.
58
72
  */
59
73
  import { controlPlaneStorePath } from '@pellux/goodvibes-sdk/platform/control-plane';
60
74
  import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
61
- import { BudgetLedger, readCvvHandling } from '@pellux/goodvibes-sdk/platform/payments';
62
- import type { PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
75
+ import { createModelMerchantJudge, readCvvHandling } from '@pellux/goodvibes-sdk/platform/payments';
76
+ import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
77
+ import { getProcessUntrustedContentLedger } from '@pellux/goodvibes-sdk/platform/security';
63
78
  import type { ConfigManager, SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
79
+ import type { ChannelDeliveryRouter } from '@pellux/goodvibes-sdk/platform/channels';
80
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
64
81
  import type { ShellPathService } from '@/runtime/index.ts';
65
82
  import {
66
83
  DaemonCardStore,
67
84
  DaemonPurchaseLedger,
85
+ DurableBudgetLedger,
86
+ channelBackedPaymentNotifier,
87
+ configBackedAddressStore,
88
+ createProviderBackedMerchantJudgeModel,
68
89
  registerPaymentsMethods,
90
+ type CheckoutComposition,
69
91
  type PaymentsSecretStore,
70
92
  } from '../daemon/handlers/payments/index.ts';
71
93
  import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
94
+ import type { BrowserCheckoutSeamHolder } from './browser-checkout-seam-holder.ts';
72
95
 
73
96
  export interface PaymentsCompositionOptions {
74
97
  readonly configManager: ConfigManager;
@@ -83,6 +106,12 @@ export interface PaymentsCompositionOptions {
83
106
  * gates.ts: on a clustered install the wrong answer is a double-spend.
84
107
  */
85
108
  readonly isPaymentsLeader: () => boolean;
109
+ /** Where the checkout pair reads the browser-checkout seam; see this file's header. */
110
+ readonly checkoutSeam: BrowserCheckoutSeamHolder['get'];
111
+ /** Delivers a purchase notice; the SAME router every other channel send in this daemon uses. */
112
+ readonly channelDeliveryRouter: Pick<ChannelDeliveryRouter, 'deliver'>;
113
+ /** Judges an unfamiliar merchant's recourse through the currently configured model. */
114
+ readonly providerRegistry: Pick<ProviderRegistry, 'getCurrentModel' | 'getForModel'>;
86
115
  }
87
116
 
88
117
  export interface PaymentsServices {
@@ -118,8 +147,13 @@ function livePaymentsConfig(configManager: ConfigManager): PaymentsConfigReader
118
147
  /**
119
148
  * Build the payment stores and bind the answerable verbs to them.
120
149
  *
121
- * Constructing this touches no disk: both stores read lazily and write only when
122
- * a verb asks them to, so composing a runtime in a test creates no files.
150
+ * The card and purchase stores read lazily and write only when a verb asks
151
+ * them to, so composing either by itself creates no file activity. The budget
152
+ * ledger is different: `DurableBudgetLedger`'s constructor reads
153
+ * `payments-budget.json` synchronously to load today's pools
154
+ * (daemon/handlers/payments/budget-store.ts), so constructing the result of
155
+ * THIS function does touch disk, once, for that one file, before any verb is
156
+ * ever called.
123
157
  */
124
158
  export function createPaymentsServices(options: PaymentsCompositionOptions): PaymentsServices {
125
159
  const config = livePaymentsConfig(options.configManager);
@@ -131,13 +165,23 @@ export function createPaymentsServices(options: PaymentsCompositionOptions): Pay
131
165
  const purchases = new DaemonPurchaseLedger({
132
166
  filePath: controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-purchases.json'),
133
167
  });
134
- const budget = new BudgetLedger();
168
+ const budget = new DurableBudgetLedger(
169
+ controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-budget.json'),
170
+ );
171
+ const checkout: CheckoutComposition = {
172
+ seam: options.checkoutSeam,
173
+ addresses: configBackedAddressStore(config),
174
+ notifier: channelBackedPaymentNotifier(config, options.channelDeliveryRouter),
175
+ merchantJudge: createModelMerchantJudge(createProviderBackedMerchantJudgeModel(options.providerRegistry)),
176
+ untrusted: getProcessUntrustedContentLedger(),
177
+ };
135
178
  const unregister = registerPaymentsMethods(options.gatewayMethods, {
136
179
  cards,
137
180
  purchases,
138
181
  budget,
139
182
  config,
140
183
  isPaymentsLeader: options.isPaymentsLeader,
184
+ checkout,
141
185
  });
142
186
  return { cards, purchases, budget, unregister };
143
187
  }
@@ -56,6 +56,7 @@ import { createTriggerServices } from './trigger-services.ts';
56
56
  import { createWorkstreamServices } from '@pellux/goodvibes-sdk/platform/orchestration';
57
57
  import { wireFleetNeedsInputPush } from './fleet-needs-input-push.ts';
58
58
  import { createDaemonHandlerComposition } from './daemon-handler-composition.ts';
59
+ import { createBrowserCheckoutSeamHolder } from './browser-checkout-seam-holder.ts';
59
60
  import { createDevicePostureServices } from './device-posture-composition.ts';
60
61
  // Re-exported so the daemon entrypoint reaches the housekeeping sweep through
61
62
  // the same module it already imports the runtime graph from. `installDevicePosture`
@@ -387,9 +388,17 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
387
388
  const { clusterGroup, clusterCoordinator } = createClusterServices({
388
389
  configManager, shellPaths, secretsManager,
389
390
  });
391
+ // ONE router, not two (a second built from the same four arguments would
392
+ // differ from the one replies leave through). Moved up from its original
393
+ // spot near the ws-only verb options (still consumed there): the payments
394
+ // composition below needs it too.
395
+ const channelDeliveryRouter = deliveryManager.getDeliveryRouter();
390
396
  // Daemon handler surfaces (see daemon-handler-composition.ts); the inbox
391
397
  // poller registers itself with the coordinator rather than starting eagerly,
392
398
  // and the payments family stops being a cataloged 501 facade there.
399
+ // browserCheckoutSeam fills once `onBrowserCheckout` fires below, after this
400
+ // composition; read lazily, per call. See browser-checkout-seam-holder.ts.
401
+ const browserCheckoutSeam = createBrowserCheckoutSeamHolder();
393
402
  const daemonHandlers = createDaemonHandlerComposition({
394
403
  gatewayMethods,
395
404
  secretsManager,
@@ -399,6 +408,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
399
408
  shellPaths,
400
409
  distributedRuntime,
401
410
  clusterCoordinator,
411
+ checkoutSeam: browserCheckoutSeam.get, channelDeliveryRouter, providerRegistry,
402
412
  });
403
413
 
404
414
  // Remote runners and the sandboxes tool calls are confined to; see
@@ -426,12 +436,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
426
436
  const policyRuntimeState = new PolicyRuntimeState();
427
437
  const fileCache = new FileStateCache();
428
438
  const projectIndex = new ProjectIndex(workingDirectory);
429
- // ONE router, not two. This was a second ChannelDeliveryRouter built from the
430
- // same four arguments AutomationDeliveryManager builds its own from, so the
431
- // router the gateway verbs held and the router replies actually leave through
432
- // were different objects, and a delivery strategy registered on one was
433
- // invisible to the other. The manager's is the one that replies; it is the one.
434
- const channelDeliveryRouter = deliveryManager.getDeliveryRouter();
439
+ // channelDeliveryRouter now built earlier, near clusterCoordinator above.
435
440
  const processManager = new ProcessManager();
436
441
  // The phase/work-item orchestration engine, constructed before the process
437
442
  // registry so its fleet nodes (workstream/phase/work-item) can be folded in
@@ -533,6 +538,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
533
538
  // is what let these stores write to the unscoped orphan directory.
534
539
  surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
535
540
  homeDirectory, emailServiceDeps, describeEmailConfigProblem, processRegistry,
541
+ onBrowserCheckout: browserCheckoutSeam.set,
536
542
  // The registration-gated surface, not the raw manager: an explicit create in
537
543
  // an unregistered workspace refuses with something actionable.
538
544
  workspaceCheckpointManager: checkpointing.gatewayManager,
@@ -564,6 +570,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
564
570
  disposal: disposalScope.registry,
565
571
  ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }),
566
572
  });
573
+ disposalScope.registry.add('browser checkout seam holder', () => browserCheckoutSeam.clear()); // newer than 'browser sessions' above, so runs first
567
574
  // A loopback fetch that isn't allow-listed asks once through the approval
568
575
  // broker; "allow for this project" persists and later fetches never ask. Built
569
576
  // once and shared with the tool registry so both ask alike.