@pellux/goodvibes-daemon 1.28.21 → 1.28.22

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/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to the GoodVibes daemon.
4
4
 
5
+ ## [1.28.22] - 2026-08-21
6
+
7
+ ### Changes
8
+
9
+ - **Interrupted checkouts are disclosed at boot.** sdk 2.0.20's
10
+ `registerPaymentsGatewayMethods` runs a recovery sweep over the durable
11
+ checkout journal before attaching the payment verbs: a checkout the last
12
+ process died holding is settled by its journaled phase (`not-submitted`
13
+ released, `possibly-submitted` disclosed to the owner once, `submitted`
14
+ reconciled against the purchase ledger) instead of being forgotten. The
15
+ daemon's five local handlers now attach after that sweep through the new
16
+ `{ ready, unregister }` registration shape, so the sdk's deferred attach can
17
+ never replace them, and a teardown that runs before attachment completes
18
+ leaves no handler behind. Recovery outcomes land on the daemon log through
19
+ the sdk's `onRecoveryFailure`/`onRecoverySettled` hooks.
20
+ - **Approving a purchase is a real recorded act.** The new
21
+ `payments.checkout.approve` verb (ws-only, confirm-gated, owner-direct from
22
+ its own code path) mints a single-use, content-bound approval with a
23
+ five-minute TTL, persisted in `payments-approvals.json`; `checkout.begin`
24
+ spends exactly one or refuses with `OWNER_APPROVAL_REQUIRED` naming the
25
+ verb. Approvals ride sdk 2.0.20's `checkOwnerApproval`, which now compares
26
+ the content fingerprint whenever one is present and the caller names the
27
+ content in question, independent of taint clearing.
28
+ - **In-flight checkouts survive restarts.** The checkout registry writes
29
+ every phase to `payments-checkout-journal.json` before proceeding — the
30
+ `submit-pending` flush lands on disk before the merchant submit — so a
31
+ crash in the one ambiguous window leaves a record the boot sweep can
32
+ disclose instead of nothing. Unknown journal fields round-trip untouched.
33
+ - **Help text tells the truth** about update checks and auth file paths.
34
+
5
35
  ## [1.28.21] - 2026-08-21
6
36
 
7
37
  ### Changes
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.28.20-blue.svg)](https://github.com/mgd34msu/goodvibes-daemon)
5
+ [![Version](https://img.shields.io/badge/version-1.28.21-blue.svg)](https://github.com/mgd34msu/goodvibes-daemon)
6
6
 
7
- The GoodVibes daemon: one long-running process per machine that holds the control plane every
8
- GoodVibes client talks to. It:
7
+ The GoodVibes daemon is the one long-running process per machine that holds the control plane
8
+ every GoodVibes client talks to. It:
9
9
 
10
10
  - answers the operator verb families over HTTP
11
11
  - reads and replies on your channels
@@ -13,6 +13,8 @@ GoodVibes client talks to. It:
13
13
  shared inbox
14
14
  - runs scheduled and triggered work
15
15
  - keeps the session, memory, knowledge and code-index stores
16
+ - serves the payment verbs, with stored cards, a daily budget that survives a restart,
17
+ and checkouts driven through the browser it operates
16
18
  - provisions the local voice and wake-word models
17
19
  - updates itself at an idle moment, with a rollback if the new binary will not start
18
20
 
@@ -25,11 +27,23 @@ here.
25
27
  A **product** over `@pellux/goodvibes-sdk`, exactly like the TUI and the agent are:
26
28
 
27
29
  - the composition root that builds the daemon's service graph,
28
- - the product handlers the SDK does not own (inbox, triage, drafts, routing, remote peers,
29
- credentials),
30
- - the CLI (`send`, `cluster`, `webui`, `provision-wake-model`, `install-service` and friends),
30
+ - the product handler families the SDK does not own, tabled below,
31
+ - the CLI, every command of which is documented in
32
+ [docs/commands-reference.md](docs/commands-reference.md),
31
33
  - packaging: the compiled `goodvibes-daemon-<os>-<arch>` binaries.
32
34
 
35
+ The handler families under `src/daemon/handlers/`:
36
+
37
+ | Family | What it serves |
38
+ | --- | --- |
39
+ | inbox | `channels.inbox.list`, the unified inbound-message list, with provider adapters for Slack, Discord and email |
40
+ | triage | the email auto-tag and spam-triage pass that decorates the inbox list with persisted triage metadata |
41
+ | drafts | `channels.drafts.*`, daemon-mirrored channel drafts for cross-device sync, webhook values stored redacted |
42
+ | routing | `channels.routing.*`, the daemon-persisted channel-to-profile routing table |
43
+ | remote | `remote.peers.*`, the peer registry and the dispatcher routing work to docker, ssh, cloud-terminal and local-process backends |
44
+ | credentials | the daemon credential store that resolves `goodvibes://secrets/...` references for the other handlers |
45
+ | payments | the `payments.*` verbs: cards, budget, purchase ledger, owner approvals and browser checkout |
46
+
33
47
  Every engine (the facade, the routes, the brokers, the updater, the channel adapters, the
34
48
  schedulers) lives in the SDK and is consumed from the published package. Nothing was moved out of
35
49
  the SDK to build this repository, and nothing should be: a capability that both a client and the
@@ -37,7 +51,8 @@ daemon need belongs in the SDK, not here.
37
51
 
38
52
  ## Version line
39
53
 
40
- The daemon's version is **1.28.0**. Live installs already carry a settings reader-floor
54
+ The daemon versions on the continuous **1.28** line; this release is **1.28.21**. Live
55
+ installs already carry a settings reader-floor
41
56
  (`$goodvibes.minReaderVersion`), the update handover compares versions monotonically, and the
42
57
  rejected-version record is keyed by version. Those three mechanics all depend on the version
43
58
  line staying continuous and monotonically increasing.
@@ -50,7 +65,8 @@ curl -fsSL https://goodvibes.sh/install.sh | sh
50
65
 
51
66
  This installs the whole GoodVibes suite (the daemon, the terminal app, the
52
67
  agent, and the browser operator surface) from checksum-verified binaries,
53
- with no package manager involved.
68
+ with no package manager involved. The product-by-product table of what
69
+ lands from where is in [docs/getting-started.md](docs/getting-started.md#install).
54
70
 
55
71
  The browser surface is not a fourth binary and not a fourth service: the bundle
56
72
  unpacks to `<install dir>/webui/<version>` and this daemon serves it on its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-daemon",
3
- "version": "1.28.21",
3
+ "version": "1.28.22",
4
4
  "description": "The GoodVibes daemon \u2014 the one long-running host for the control plane, channels, cluster membership, scheduled work, knowledge and memory stores, and the verb families every GoodVibes client calls.",
5
5
  "type": "module",
6
6
  "main": "src/daemon/cli.ts",
@@ -67,8 +67,8 @@
67
67
  "@anthropic-ai/bedrock-sdk": "^0.28.1",
68
68
  "@anthropic-ai/sdk": "^0.82.0",
69
69
  "@ast-grep/napi": "^0.42.0",
70
- "@pellux/goodvibes-sdk": "2.0.19",
71
- "@pellux/goodvibes-terminal-shell": "2.0.19",
70
+ "@pellux/goodvibes-sdk": "2.0.20",
71
+ "@pellux/goodvibes-terminal-shell": "2.0.20",
72
72
  "bash-language-server": "^5.6.0",
73
73
  "fuse.js": "^7.1.0",
74
74
  "graphql": "^16.13.2",
@@ -134,7 +134,7 @@ export const GLOBAL_FLAGS: readonly DaemonCommandFlagSpec[] = [
134
134
  field: 'daemonHome',
135
135
  kind: 'string',
136
136
  valueName: 'dir',
137
- summary: "The daemon's own identity directory (operator tokens, auth users, daemon settings).",
137
+ summary: "The daemon's own identity directory (operator tokens, daemon settings).",
138
138
  },
139
139
  {
140
140
  tokens: ['--working-dir', '--cd', '-C'],
@@ -554,16 +554,17 @@ export const DAEMON_COMMANDS: readonly DaemonCommandSpec[] = [
554
554
  'updates and restarts, the version an automatic rollback rejected (if any), and',
555
555
  'whether a rollback is currently in force.',
556
556
  '',
557
- '--check asks the daemon to look for a new release now. The daemon checks hourly',
558
- 'on its own and swaps only at an idle moment; this command exists for the case',
559
- 'where you do not want to wait for the next hour.',
557
+ '--check is honest about a gap: the control plane publishes no verb to trigger',
558
+ 'an update check early, so it says so plainly and names the two things that do',
559
+ 'work: waiting for the hourly check, or restarting the service, which checks on',
560
+ 'the way up.',
560
561
  '',
561
562
  'The rollback and receipt lines are read from files the daemon writes on its own',
562
563
  'host, so they are reported for a local daemon and named as unavailable for a',
563
564
  'remote one.',
564
565
  ],
565
566
  flags: [
566
- { tokens: ['--check'], field: 'check', kind: 'boolean', summary: 'Ask for an update check now instead of waiting for the hourly one.' },
567
+ { tokens: ['--check'], field: 'check', kind: 'boolean', summary: 'Explain that no early-check verb exists, and name the two paths that work.' },
567
568
  JSON_FLAG,
568
569
  ...REMOTE_TARGET_FLAGS,
569
570
  ],
package/src/daemon/cli.ts CHANGED
@@ -74,7 +74,7 @@ type DaemonCliOwnership = {
74
74
  readonly workingDirectory: string;
75
75
  /** The GoodVibes tree root, settings, workspace, and discovery all hang off this. */
76
76
  readonly homeDirectory: string;
77
- /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
77
+ /** The daemon's OWN identity home (operator tokens, daemon settings). */
78
78
  readonly daemonHomeDirectory: string;
79
79
  /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override, see goodvibes-home.ts. */
80
80
  readonly isOverridden: boolean;
@@ -91,7 +91,7 @@ type DaemonCliTokens = {
91
91
  * Two different directories that used to be one.
92
92
  *
93
93
  * `GOODVIBES_DAEMON_HOME` names the DAEMON's home, the identity directory
94
- * holding auth-users.json, operator-tokens.json, and daemon-settings.json. That
94
+ * holding operator-tokens.json and the daemon tier's settings.json. That
95
95
  * is what the name says and what the SDK's `resolveDaemonHomeDir()` has always
96
96
  * meant by it. This function used to read it as the GoodVibes tree ROOT, so
97
97
  * setting it relocated settings, workspace, and every discovery root as well,
@@ -372,7 +372,7 @@ async function main(): Promise<void> {
372
372
  // derivation: `daemonHomeDirectory` is this process's own resolution of
373
373
  // `--daemon-home`/`GOODVIBES_DAEMON_HOME`, and the two must agree so the
374
374
  // daemon's config reads and writes the same file its identity state
375
- // (operator-tokens.json, auth-users.json) already lives beside.
375
+ // (operator-tokens.json) already lives beside.
376
376
  const config = new ConfigManager({
377
377
  workingDir,
378
378
  homeDir: homeDirectory,
@@ -0,0 +1,275 @@
1
+ /**
2
+ * approval-store.ts, the persisted owner approvals a checkout spends.
3
+ *
4
+ * ── What this holds ───────────────────────────────────────────────────────
5
+ *
6
+ * `OwnerApproval` records the SDK's own factory minted (`grantOwnerApproval`,
7
+ * platform/security/owner-approval.ts), one per purchase the owner has
8
+ * approved and `payments.checkout.begin` has not yet consumed. The SDK's
9
+ * `OwnerApprovalStore` keeps them in process memory, which is right for a
10
+ * surface whose gesture and action share a process. This daemon's approve
11
+ * verb and its begin verb are separate control-plane calls that may straddle
12
+ * a daemon restart, so the records live in a file, the same convention every
13
+ * other payments store here follows (`payments-cards.json` and friends,
14
+ * composed by runtime/payments-composition.ts).
15
+ *
16
+ * ── The four properties, kept ─────────────────────────────────────────────
17
+ *
18
+ * The SDK's ruling names four properties and this store must not weaken any:
19
+ *
20
+ * 1. Owner-direct only: `grant` passes `surface: 'owner-direct'` from ITS
21
+ * OWN code path, never from an argument, so nothing a caller sends can
22
+ * name a different surface. The verb that calls `grant` is itself behind
23
+ * this daemon's confirmation gate (`assertConfirmed`, register.ts).
24
+ * 2. Content-bound: the fingerprint is the SDK's, over the exact fields the
25
+ * owner approved, and `take` matches with `checkOwnerApproval`, never by
26
+ * action id alone.
27
+ * 3. Short-lived: TTL is the SDK's `OWNER_APPROVAL_TTL_MS` (five minutes),
28
+ * not configurable through the wire, and expired records are swept on
29
+ * every access and at load.
30
+ * 4. Single use: `take` removes what it returns, and the removal is
31
+ * persisted before the approval is handed back, so a taken approval
32
+ * cannot be respent after a restart. A persist that fails rolls the
33
+ * removal back and throws, leaving the approval intact rather than in a
34
+ * state where memory and disk disagree about whether it is spendable.
35
+ *
36
+ * ── Durability conventions ────────────────────────────────────────────────
37
+ *
38
+ * Same as `DurableBudgetLedger` (budget-store.ts): a missing file is the
39
+ * ordinary first-boot case and starts empty silently; a file that exists but
40
+ * cannot be parsed, or does not hold this store's shape, is data loss for a
41
+ * store that authorizes spending, so it is logged as a warning naming the
42
+ * file and the store starts empty; entries that fail the record checks are
43
+ * dropped with a warning rather than trusted. Writes are atomic
44
+ * (`atomicWriteFileSync`, mode 0600).
45
+ */
46
+ import { existsSync, readFileSync } from 'node:fs';
47
+ import { atomicWriteFileSync } from '@pellux/goodvibes-sdk/platform/config';
48
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
49
+ import {
50
+ checkOwnerApproval,
51
+ grantOwnerApproval,
52
+ type ApprovalMismatch,
53
+ type OwnerApproval,
54
+ } from '@pellux/goodvibes-sdk/platform/security';
55
+
56
+ const APPROVALS_FILE_VERSION = 1;
57
+
58
+ /**
59
+ * Bounded the way the SDK's own store is: a caller that mints without
60
+ * spending must not grow the file without limit. The oldest grant is evicted
61
+ * to make room, which is also the least spendable one, since TTLs expire in
62
+ * grant order.
63
+ */
64
+ const MAX_PENDING_APPROVALS = 16;
65
+
66
+ interface ApprovalsFile {
67
+ readonly version: number;
68
+ readonly approvals: readonly OwnerApproval[];
69
+ }
70
+
71
+ function isOwnerApproval(value: unknown): value is OwnerApproval {
72
+ if (typeof value !== 'object' || value === null) return false;
73
+ const record = value as Record<string, unknown>;
74
+ return typeof record['action'] === 'string'
75
+ && typeof record['grantedAt'] === 'string'
76
+ && typeof record['expiresAt'] === 'string'
77
+ && Number.isFinite(Date.parse(record['expiresAt'] as string))
78
+ && record['surface'] === 'owner-direct'
79
+ && (record['contentFingerprint'] === null || typeof record['contentFingerprint'] === 'string');
80
+ }
81
+
82
+ /** Read the persisted approvals, or an empty list for "start empty". */
83
+ function loadInitialApprovals(filePath: string): OwnerApproval[] {
84
+ if (!existsSync(filePath)) return [];
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
88
+ } catch (error) {
89
+ logger.warn(
90
+ 'Owner-approval file could not be read; starting with no pending approvals rather than guessing. '
91
+ + 'Any approval granted before this must be granted again.',
92
+ { filePath, error: error instanceof Error ? error.message : String(error) },
93
+ );
94
+ return [];
95
+ }
96
+ if (
97
+ typeof parsed !== 'object'
98
+ || parsed === null
99
+ || !Array.isArray((parsed as Partial<ApprovalsFile>).approvals)
100
+ ) {
101
+ logger.warn(
102
+ 'Owner-approval file does not hold the expected shape; starting with no pending approvals rather than guessing.',
103
+ { filePath },
104
+ );
105
+ return [];
106
+ }
107
+ const rows = (parsed as ApprovalsFile).approvals;
108
+ const approvals = rows.filter(isOwnerApproval);
109
+ if (approvals.length !== rows.length) {
110
+ logger.warn(
111
+ 'Owner-approval file held entries that do not match the approval record shape; those entries were '
112
+ + 'dropped rather than trusted.',
113
+ { filePath },
114
+ );
115
+ }
116
+ return approvals;
117
+ }
118
+
119
+ /** What `take` reports when nothing matched, so the refusal can say which way it missed. */
120
+ export interface ApprovalTakeMiss {
121
+ readonly approval: null;
122
+ readonly mismatch: ApprovalMismatch;
123
+ }
124
+
125
+ export interface ApprovalTakeHit {
126
+ readonly approval: OwnerApproval;
127
+ }
128
+
129
+ /**
130
+ * The persisted, single-use, content-bound approvals this daemon's checkout
131
+ * spends. See the module header for the properties this must keep.
132
+ */
133
+ export class DaemonApprovalStore {
134
+ private approvals: OwnerApproval[];
135
+
136
+ constructor(
137
+ private readonly filePath: string,
138
+ private readonly now: () => Date = () => new Date(),
139
+ ) {
140
+ this.approvals = loadInitialApprovals(filePath);
141
+ // Load-time sweep: an approval that expired while the daemon was down is
142
+ // not spendable and not worth carrying. Quiet, and only writes when it
143
+ // actually dropped something.
144
+ if (this.sweep()) this.persist();
145
+ }
146
+
147
+ /**
148
+ * Record an approval the owner has just given.
149
+ *
150
+ * `surface: 'owner-direct'` is supplied HERE, from the code path, which is
151
+ * the property the SDK's factory exists to enforce; no argument of this
152
+ * method can name a surface. Throws when the record cannot be persisted,
153
+ * rolling the in-memory grant back first: an approval the disk never saw
154
+ * must not be spendable from memory, or a restart changes what the owner
155
+ * authorized.
156
+ */
157
+ grant(input: {
158
+ readonly action: string;
159
+ readonly content: Readonly<Record<string, string | undefined>>;
160
+ }): OwnerApproval {
161
+ this.sweep();
162
+ const approval = grantOwnerApproval({
163
+ action: input.action,
164
+ surface: 'owner-direct',
165
+ content: input.content,
166
+ now: this.now,
167
+ });
168
+ // Unreachable through this class (the surface above is fixed), kept as a
169
+ // thrown error rather than a narrowing cast so a future SDK change that
170
+ // makes the factory refuse for a new reason fails loudly here.
171
+ if (approval === null) throw new Error('grantOwnerApproval refused an owner-direct grant');
172
+ const evicted = this.approvals.length >= MAX_PENDING_APPROVALS ? this.approvals.shift() : undefined;
173
+ this.approvals.push(approval);
174
+ try {
175
+ this.persist();
176
+ } catch (error) {
177
+ this.approvals.pop();
178
+ if (evicted) this.approvals.unshift(evicted);
179
+ throw error;
180
+ }
181
+ if (evicted) {
182
+ logger.warn(
183
+ 'Owner-approval store was full; the oldest pending approval was evicted to record this one.',
184
+ { filePath: this.filePath, evictedAction: evicted.action, evictedGrantedAt: evicted.grantedAt },
185
+ );
186
+ }
187
+ return approval;
188
+ }
189
+
190
+ /**
191
+ * Spend the one approval matching this action and payload, if one is held.
192
+ *
193
+ * Removes what it returns, and persists the removal BEFORE returning, so a
194
+ * spent approval is spent on disk too. A miss reports the closest mismatch
195
+ * so the refusal can tell the owner whether to approve again (expired,
196
+ * none) or to check what they approved (different content).
197
+ */
198
+ take(input: {
199
+ readonly action: string;
200
+ readonly content: Readonly<Record<string, string | undefined>>;
201
+ }): ApprovalTakeHit | ApprovalTakeMiss {
202
+ let closest: ApprovalMismatch = 'none';
203
+ for (let index = 0; index < this.approvals.length; index += 1) {
204
+ const candidate = this.approvals[index]!;
205
+ const verdict = checkOwnerApproval({
206
+ approval: candidate,
207
+ action: input.action,
208
+ contentInQuestion: input.content,
209
+ // Deliberately `true` even though no taint finding is being cleared:
210
+ // the SDK's check only compares the content fingerprint under this
211
+ // flag (`checkOwnerApproval`, owner-approval.ts), and without the
212
+ // comparison an unexpired approval authorizes by ACTION ID alone,
213
+ // which is exactly the "matching on the verb, not on the deed"
214
+ // failure the ruling names. Setting it also refuses the weak,
215
+ // fingerprint-less form (`no-content-binding`), which this store
216
+ // never mints but must not honor if one appears in the file.
217
+ clearingContentTaint: true,
218
+ now: this.now,
219
+ });
220
+ if (verdict.authorized) {
221
+ this.approvals.splice(index, 1);
222
+ try {
223
+ this.persist();
224
+ } catch (error) {
225
+ this.approvals.splice(index, 0, candidate);
226
+ throw error;
227
+ }
228
+ return { approval: candidate };
229
+ }
230
+ closest = closerMismatch(closest, verdict.mismatch);
231
+ }
232
+ // Swept AFTER matching, not before: an approval that expired must be
233
+ // reported as `expired` (the owner approves again), not silently dropped
234
+ // first and reported as `none` (the owner is told nothing was approved).
235
+ // The sweep still runs on every miss, so expired records do not linger.
236
+ if (this.sweep()) this.persist();
237
+ return { approval: null, mismatch: closest };
238
+ }
239
+
240
+ /** Whether anything is currently spendable, for a status line or a test. */
241
+ pendingCount(): number {
242
+ this.sweep();
243
+ return this.approvals.length;
244
+ }
245
+
246
+ /** Drop expired records. Returns true when it dropped any. */
247
+ private sweep(): boolean {
248
+ const nowMs = this.now().getTime();
249
+ const before = this.approvals.length;
250
+ this.approvals = this.approvals.filter((approval) => Date.parse(approval.expiresAt) > nowMs);
251
+ return this.approvals.length !== before;
252
+ }
253
+
254
+ private persist(): void {
255
+ const contents: ApprovalsFile = { version: APPROVALS_FILE_VERSION, approvals: this.approvals };
256
+ atomicWriteFileSync(this.filePath, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600, mkdirp: true });
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Which mismatch is the more useful one to name, when several records each
262
+ * missed differently. Content mismatches beat expiry beats wrong-action beats
263
+ * nothing-held, because each earlier one implies the owner did something
264
+ * closer to approving THIS purchase.
265
+ */
266
+ function closerMismatch(current: ApprovalMismatch, candidate: ApprovalMismatch): ApprovalMismatch {
267
+ const rank: Record<ApprovalMismatch, number> = {
268
+ 'none': 0,
269
+ 'different-action': 1,
270
+ 'expired': 2,
271
+ 'no-content-binding': 3,
272
+ 'different-content': 4,
273
+ };
274
+ return rank[candidate] > rank[current] ? candidate : current;
275
+ }
@@ -177,8 +177,9 @@ function loadInitialState(filePath: string): BudgetStateSnapshot | undefined {
177
177
  { filePath },
178
178
  );
179
179
  }
180
- // Named, not just counted: there is no durable journal for a reservation
181
- // (unlike the checkout journal's own disclosed gap, see register.ts), so this
180
+ // Named, not just counted: a reservation has no per-purchase narrative of
181
+ // its own here (the checkout journal, checkout-journal-store.ts, records
182
+ // the purchase's phases; this file records only the pools), so this
182
183
  // log line is the only record an operator has, at restart, of money that is
183
184
  // currently held against the daily limit for a purchase that may or may not
184
185
  // still be in flight. Reconciling it against what the merchant actually
@@ -40,6 +40,7 @@ import type { UntrustedContentLedger } from '@pellux/goodvibes-sdk/platform/secu
40
40
  import type { BrowserCheckoutSeam } from '../contracts.ts';
41
41
  import { HandlerError } from '../errors.ts';
42
42
  import type { TypedHandler } from '../register.ts';
43
+ import type { DaemonApprovalStore } from './approval-store.ts';
43
44
  import type { DaemonCardStore } from './card-store.ts';
44
45
  import type { PaymentsHandlerDeps } from './register.ts';
45
46
 
@@ -70,6 +71,20 @@ export interface CheckoutComposition {
70
71
  readonly merchantJudge: MerchantJudgePort;
71
72
  /** The process-wide ledger; see routes/browser-composition.ts's header for why it must be shared, not private. */
72
73
  readonly untrusted: UntrustedContentLedger;
74
+ /**
75
+ * The persisted, single-use approvals `payments.checkout.approve` mints and
76
+ * `payments.checkout.begin` spends. See approval-store.ts for the four
77
+ * properties the store keeps, and `checkoutBeginHandler` below for where
78
+ * one is consumed.
79
+ */
80
+ readonly approvals: DaemonApprovalStore;
81
+ /**
82
+ * The journal the shared service's in-flight registry writes through. The
83
+ * real daemon composes `DurableCheckoutJournal`
84
+ * (checkout-journal-store.ts) so a `submit-pending` record survives a
85
+ * restart; tests may compose the SDK's `MemoryCheckoutJournal`.
86
+ */
87
+ readonly journal: CheckoutJournal;
73
88
  }
74
89
 
75
90
  function invalid(field: string, requirement: string): HandlerError {
@@ -386,6 +401,128 @@ const CHECKOUT_UNAVAILABLE_MESSAGE =
386
401
  + 'configured, or the browser composition has not finished starting). Retry once the daemon has finished '
387
402
  + 'booting; if this persists, the daemon was started without a home directory to keep browser profiles in.';
388
403
 
404
+ /**
405
+ * The action an owner approval authorizes: one `payments.checkout.begin`.
406
+ * The approve verb mints against this constant and `begin` spends against it,
407
+ * so the two can never drift into approving one verb and spending on another.
408
+ */
409
+ export const CHECKOUT_APPROVAL_ACTION = 'payments.checkout.begin';
410
+
411
+ /**
412
+ * The exact fields an approval binds, built the same way on both sides.
413
+ *
414
+ * On the approve side the values are what the owner typed; on the begin side
415
+ * they are read from the begin call itself (`merchantDomain`, `item`,
416
+ * `requestedMax`). One builder for both is what makes the fingerprint a
417
+ * comparison of the deed rather than two modules' ideas of it.
418
+ */
419
+ export function checkoutApprovalContent(input: {
420
+ readonly merchantDomain: string;
421
+ readonly item: string;
422
+ readonly amount: string | undefined;
423
+ }): Readonly<Record<string, string | undefined>> {
424
+ return { merchant: input.merchantDomain, item: input.item, amount: input.amount };
425
+ }
426
+
427
+ /** How a begin call names the approve verb when it refuses, per mismatch. */
428
+ function approvalRefusalMessage(mismatch: string): string {
429
+ if (mismatch === 'expired') {
430
+ return 'The owner approval for this purchase has expired. Approvals last five minutes: call '
431
+ + 'payments.checkout.approve again with the same merchantDomain, item and amount, then begin promptly.';
432
+ }
433
+ if (mismatch === 'different-content' || mismatch === 'no-content-binding') {
434
+ return 'The owner approval on file was for a different purchase: its merchant, item or amount does not '
435
+ + 'match this begin call (the amount is compared against requestedMax). Call payments.checkout.approve '
436
+ + 'with exactly what this begin call names, then begin again.';
437
+ }
438
+ return 'This purchase has no owner approval on file. A human approves it first, out of band from this '
439
+ + 'call: invoke payments.checkout.approve with this purchase\'s merchantDomain, item and amount (the '
440
+ + 'begin call\'s requestedMax), then begin within five minutes.';
441
+ }
442
+
443
+ /**
444
+ * Spend the one approval matching this begin call, or refuse naming the
445
+ * approve verb. Consuming before the service runs is the sdk store's own
446
+ * safe direction: an approval taken for a begin that then refuses on a later
447
+ * gate is spent, never silently reusable.
448
+ */
449
+ function consumeCheckoutApproval(
450
+ approvals: DaemonApprovalStore,
451
+ input: { readonly merchantDomain: string; readonly item: string; readonly requestedMax?: string | undefined },
452
+ ): void {
453
+ let taken: ReturnType<DaemonApprovalStore['take']>;
454
+ try {
455
+ taken = approvals.take({
456
+ action: CHECKOUT_APPROVAL_ACTION,
457
+ content: checkoutApprovalContent({
458
+ merchantDomain: input.merchantDomain,
459
+ item: input.item,
460
+ amount: input.requestedMax,
461
+ }),
462
+ });
463
+ } catch (error) {
464
+ // A store that could not persist the removal rolled it back and threw
465
+ // (approval-store.ts): the approval is still on file and nothing was
466
+ // submitted. Contained: the raw error can name the store path.
467
+ void error;
468
+ throw new HandlerError(
469
+ 'Recording the spent approval failed. The approval was not consumed and nothing was submitted.',
470
+ 'INTERNAL_ERROR',
471
+ 500,
472
+ );
473
+ }
474
+ if (taken.approval === null) {
475
+ throw new HandlerError(approvalRefusalMessage(taken.mismatch), 'OWNER_APPROVAL_REQUIRED', 403);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * `payments.checkout.approve`.
481
+ *
482
+ * The distinct act the sdk's owner-approval ruling requires: a HUMAN, on a
483
+ * surface with command authority, names one purchase and approves it. The
484
+ * registration (register.ts) puts this handler behind the same confirmation
485
+ * gate every destructive verb in this daemon uses (`confirm: true` in the
486
+ * body AND the explicit-user-request context flag), and the handler passes
487
+ * `surface: 'owner-direct'` from its own code path, never from an argument,
488
+ * which is the property `grantOwnerApproval` exists to enforce.
489
+ *
490
+ * The minted record is persisted (approval-store.ts), single-use, bound to
491
+ * the exact merchant + item + amount fields named here, and expires in five
492
+ * minutes. `payments.checkout.begin` spends it; see `checkoutBeginHandler`.
493
+ */
494
+ export function checkoutApproveHandler(deps: PaymentsHandlerDeps): TypedHandler<unknown, Record<string, unknown>> {
495
+ return async ({ body }) => {
496
+ const params = asRecord(body);
497
+ const merchantDomain = requireString(params['merchantDomain'], 'merchantDomain');
498
+ const item = requireString(params['item'], 'item');
499
+ const amount = requireString(params['amount'], 'amount');
500
+ let approval;
501
+ try {
502
+ approval = deps.checkout.approvals.grant({
503
+ action: CHECKOUT_APPROVAL_ACTION,
504
+ content: checkoutApprovalContent({ merchantDomain, item, amount }),
505
+ });
506
+ } catch (error) {
507
+ // Contained for the same reason as `consumeCheckoutApproval`: the raw
508
+ // write failure can name the store path, and an approval that never
509
+ // reached disk was deliberately rolled back rather than left spendable.
510
+ void error;
511
+ throw new HandlerError('Recording the approval failed. Nothing was approved.', 'INTERNAL_ERROR', 500);
512
+ }
513
+ // Named fields, never a spread, the same containment rule as every other
514
+ // response in this family.
515
+ return {
516
+ approved: true,
517
+ action: CHECKOUT_APPROVAL_ACTION,
518
+ merchantDomain,
519
+ item,
520
+ amount,
521
+ expiresAt: approval.expiresAt,
522
+ };
523
+ };
524
+ }
525
+
389
526
  /** The sdk's own explicit nine-field projection (routes/payments.ts's `createPaymentsCheckoutBeginHandler`), not a spread. */
390
527
  function beginResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['beginCheckout']>>): Record<string, unknown> {
391
528
  return {
@@ -417,29 +554,34 @@ function beginResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['
417
554
  * (`security/turn-boundary.ts`), a second, separate effect of the same
418
555
  * caller-set claim, not something this handler arranges.
419
556
  *
420
- * This composition used to also arm `seam.armSubmitApproval` here, minting an
421
- * `OwnerApproval` for a `'payments.checkout.submit'` action whenever
422
- * `explicitUserRequest` was true. That mechanism has been deleted: it never
423
- * actually did anything. The browser engine only ever checks an armed
424
- * approval's action against the literal string `'browser.submit'`
425
- * (browser-engine.ts), so an approval minted for `'payments.checkout.submit'`
426
- * never matched it and the check always fell through to `different-action`.
427
- * Even with the names made to agree, an approval minted here with no
428
- * `content` argument carries `contentFingerprint: null`, the WEAK form
429
- * (`owner-approval.ts`), which clears only a refusal that was itself made
430
- * without content and never a content-derivation finding, so repairing the
431
- * name would still not have cleared anything real. A distinct, genuine
432
- * owner-approval record for a purchase, minted from a separate interactive
433
- * act and bound to the exact payload approved, is real future work (see
434
- * `.goodvibes/memory/decisions.json`), and a mechanism that LOOKED like that
435
- * record while clearing nothing was worse than having none.
557
+ * ── The genuine owner-approval record, consumed here ───────────────────────
558
+ *
559
+ * Behind that outer gate sits the distinct-act approval the sdk's
560
+ * owner-approval ruling describes (platform/security/owner-approval.ts): a
561
+ * persisted record that a human called `payments.checkout.approve`, out of
562
+ * band from whatever conversation produced this begin call, naming ONE
563
+ * purchase by merchant, item and amount. This handler spends exactly one
564
+ * matching record per begin (`DaemonApprovalStore.take`, approval-store.ts:
565
+ * single-use, content-bound via the sdk's own fingerprint, five-minute TTL)
566
+ * and refuses, naming the approve verb, when none matches. The record is
567
+ * consumed BEFORE the service runs, which is the sdk store's own safe
568
+ * direction: a taken approval whose begin then refuses on a later gate is
569
+ * spent, never quietly retried.
570
+ *
571
+ * The binding fields are `merchantDomain`, `item` and `requestedMax`, read
572
+ * from THIS begin call and fingerprinted the same way the approve verb
573
+ * fingerprinted what the owner typed, so a begin whose merchant, item or
574
+ * amount differs from what was approved is `different-content`, not a match.
575
+ * An earlier mechanism that armed `seam.armSubmitApproval` with a
576
+ * content-free approval was deleted rather than shipped, because an approval
577
+ * with no content binding clears nothing real; this record is the strong
578
+ * form, minted with the exact fields.
436
579
  *
437
- * The real money controls, downstream of this gate, are the sdk's own
438
- * checkout ladder: the budget ledger (RESERVE, step 5), the purchase notices
439
- * and their approval/veto decision windows (NOTICE + WINDOW, step 6), and the
440
- * card-material guard (`cardFieldGuard`, armed only immediately before
441
- * typing, never before). See `checkout-flow.ts`'s own header for the full
442
- * order.
580
+ * The money controls downstream of both gates are unchanged: the budget
581
+ * ledger (RESERVE, step 5), the purchase notices and their approval/veto
582
+ * decision windows (NOTICE + WINDOW, step 6), and the card-material guard
583
+ * (`cardFieldGuard`, armed only immediately before typing, never before).
584
+ * See `checkout-flow.ts`'s own header for the full order.
443
585
  */
444
586
  export function checkoutBeginHandler(deps: PaymentsHandlerDeps, holder: CheckoutServiceHolder): TypedHandler<unknown, Record<string, unknown>> {
445
587
  return async ({ body, context }) => {
@@ -451,6 +593,16 @@ export function checkoutBeginHandler(deps: PaymentsHandlerDeps, holder: Checkout
451
593
  throw new HandlerError(CHECKOUT_UNAVAILABLE_MESSAGE, 'FAILED_PRECONDITION', 409);
452
594
  }
453
595
 
596
+ // The approval is only consulted INSIDE the outer explicitUserRequest
597
+ // layer: a call that never claimed to be owner-direct falls through to
598
+ // the service, whose own gate refuses it `refused:not-owner-request`
599
+ // exactly as before this record existed. Consuming an approval for a
600
+ // call that outer layer was always going to refuse would spend the
601
+ // owner's answer on nothing.
602
+ if (context.explicitUserRequest) {
603
+ consumeCheckoutApproval(deps.checkout.approvals, input);
604
+ }
605
+
454
606
  const [usableCard, shippingAddress] = await Promise.all([
455
607
  hasUsableCard(deps.cards, input.cardId),
456
608
  hasShippingAddress(deps.checkout.addresses),
@@ -0,0 +1,162 @@
1
+ /**
2
+ * checkout-journal-store.ts, the in-flight checkout journal, durable across a
3
+ * restart.
4
+ *
5
+ * ── The defect this closes ────────────────────────────────────────────────
6
+ *
7
+ * The composition used to hand `PaymentsGatewayServiceImpl` the SDK's own
8
+ * `MemoryCheckoutJournal`, documented "durable across nothing". The phase
9
+ * ladder in checkout-registry.ts exists for exactly one ambiguous moment: a
10
+ * crash between the `submit-pending` flush and the merchant's response. With
11
+ * an in-memory journal that flush kept nothing, so a restart could never say
12
+ * "this purchase may already have been submitted, do not resubmit it". This
13
+ * file is the durable journal that record was waiting for.
14
+ *
15
+ * ── The contract this implements ──────────────────────────────────────────
16
+ *
17
+ * The SDK's `CheckoutJournal` (checkout-registry.ts): `put` must not return
18
+ * until the record would survive a power cut, `remove` drops one by
19
+ * purchaseId, `list` returns what is held. `put` here writes through
20
+ * `atomicWriteFileSync` (a synchronous rename-into-place) before resolving,
21
+ * which is the flush the `submit-pending` guarantee rides on; a `put` whose
22
+ * write fails THROWS, because a journal that reports durable-and-was-not
23
+ * turns that guarantee into a comment.
24
+ *
25
+ * `remove` is the one deliberate asymmetry: its write failure is logged and
26
+ * swallowed rather than thrown. By the time `remove` runs the checkout is
27
+ * finished or abandoned; failing the caller would turn a completed purchase's
28
+ * report into an error over a cleanup write, and the stale record it leaves
29
+ * on disk fails in the safe direction, a restart discloses a checkout that
30
+ * needs checking rather than forgetting one that does. The in-memory removal
31
+ * stands either way, and the next successful `put`/`remove` rewrites the file
32
+ * without the stale record.
33
+ *
34
+ * ── Unknown fields ride along untouched ───────────────────────────────────
35
+ *
36
+ * Records are persisted and reloaded as the objects they arrive as, not
37
+ * projected through this module's idea of the record shape. Only
38
+ * `purchaseId` (the removal key) is checked at load; every other field,
39
+ * including fields added by an SDK this build has never seen, round-trips
40
+ * byte-for-byte. The repin that teaches
41
+ * the SDK to recover these records must find everything its writer put here,
42
+ * not everything this file knew to keep.
43
+ *
44
+ * ── Corruption is a warning, not a crash ──────────────────────────────────
45
+ *
46
+ * Same conventions as `DurableBudgetLedger` (budget-store.ts): a missing file
47
+ * starts empty silently; a file that exists but cannot be parsed or does not
48
+ * hold this shape is logged as a warning naming the file, and the journal
49
+ * starts empty rather than taking the daemon down. Entries without a string
50
+ * `purchaseId` are dropped with a warning, since nothing could ever remove
51
+ * them.
52
+ */
53
+ import { existsSync, readFileSync } from 'node:fs';
54
+ import { atomicWriteFileSync } from '@pellux/goodvibes-sdk/platform/config';
55
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
56
+ import type { CheckoutJournal, InFlightCheckout } from '@pellux/goodvibes-sdk/platform/payments';
57
+
58
+ const JOURNAL_FILE_VERSION = 1;
59
+
60
+ interface JournalFile {
61
+ readonly version: number;
62
+ readonly records: readonly Record<string, unknown>[];
63
+ }
64
+
65
+ /** The two fields this module actually reads; everything else is opaque cargo. */
66
+ function hasJournalKeys(value: unknown): value is Record<string, unknown> & { purchaseId: string } {
67
+ return typeof value === 'object'
68
+ && value !== null
69
+ && !Array.isArray(value)
70
+ && typeof (value as Record<string, unknown>)['purchaseId'] === 'string';
71
+ }
72
+
73
+ /** Read the persisted records, keyed by purchaseId, or empty for "start empty". */
74
+ function loadInitialRecords(filePath: string): Map<string, Record<string, unknown>> {
75
+ const records = new Map<string, Record<string, unknown>>();
76
+ if (!existsSync(filePath)) return records;
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
80
+ } catch (error) {
81
+ logger.warn(
82
+ 'Checkout journal file could not be read; starting with no in-flight checkouts rather than guessing. '
83
+ + 'If a purchase was mid-submit when this daemon last stopped, check that merchant\'s order history by hand.',
84
+ { filePath, error: error instanceof Error ? error.message : String(error) },
85
+ );
86
+ return records;
87
+ }
88
+ if (
89
+ typeof parsed !== 'object'
90
+ || parsed === null
91
+ || !Array.isArray((parsed as Partial<JournalFile>).records)
92
+ ) {
93
+ logger.warn(
94
+ 'Checkout journal file does not hold the expected shape; starting with no in-flight checkouts rather than guessing.',
95
+ { filePath },
96
+ );
97
+ return records;
98
+ }
99
+ const rows = (parsed as JournalFile).records;
100
+ for (const row of rows) {
101
+ if (hasJournalKeys(row)) {
102
+ records.set(row.purchaseId, row);
103
+ }
104
+ }
105
+ if (records.size !== rows.length) {
106
+ logger.warn(
107
+ 'Checkout journal file held entries with no purchaseId; those entries were dropped rather than trusted, '
108
+ + 'since nothing could ever remove them.',
109
+ { filePath },
110
+ );
111
+ }
112
+ return records;
113
+ }
114
+
115
+ /**
116
+ * The durable `CheckoutJournal` this daemon composes. See the module header
117
+ * for the contract and the conventions.
118
+ */
119
+ export class DurableCheckoutJournal implements CheckoutJournal {
120
+ private readonly records: Map<string, Record<string, unknown>>;
121
+
122
+ constructor(private readonly filePath: string) {
123
+ this.records = loadInitialRecords(filePath);
124
+ }
125
+
126
+ /** Durable before it resolves; throws when the write does not land. */
127
+ async put(record: InFlightCheckout): Promise<void> {
128
+ const previous = this.records.get(record.purchaseId);
129
+ this.records.set(record.purchaseId, record as unknown as Record<string, unknown>);
130
+ try {
131
+ this.persist();
132
+ } catch (error) {
133
+ if (previous === undefined) this.records.delete(record.purchaseId);
134
+ else this.records.set(record.purchaseId, previous);
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ /** Removes in memory always; a failed cleanup write is logged, never thrown. See the header. */
140
+ async remove(purchaseId: string): Promise<void> {
141
+ if (!this.records.delete(purchaseId)) return;
142
+ try {
143
+ this.persist();
144
+ } catch (error) {
145
+ logger.warn(
146
+ 'Checkout journal could not be rewritten after removing a finished checkout. The stale record stays on '
147
+ + 'disk until the next journal write lands; at worst a restart reports a checkout that needs checking '
148
+ + 'when it was already complete, never the reverse.',
149
+ { filePath: this.filePath, purchaseId, error: error instanceof Error ? error.message : String(error) },
150
+ );
151
+ }
152
+ }
153
+
154
+ async list(): Promise<readonly InFlightCheckout[]> {
155
+ return [...this.records.values()] as unknown as readonly InFlightCheckout[];
156
+ }
157
+
158
+ private persist(): void {
159
+ const contents: JournalFile = { version: JOURNAL_FILE_VERSION, records: [...this.records.values()] };
160
+ atomicWriteFileSync(this.filePath, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600, mkdirp: true });
161
+ }
162
+ }
@@ -26,6 +26,13 @@ export type { DaemonPurchaseLedgerOptions, PurchaseListQuery, StoredPurchase } f
26
26
 
27
27
  export { DurableBudgetLedger } from './budget-store.ts';
28
28
 
29
+ export { DaemonApprovalStore } from './approval-store.ts';
30
+ export type { ApprovalTakeHit, ApprovalTakeMiss } from './approval-store.ts';
31
+
32
+ export { DurableCheckoutJournal } from './checkout-journal-store.ts';
33
+
34
+ export { CHECKOUT_APPROVAL_ACTION, checkoutApprovalContent } from './checkout-handlers.ts';
35
+
29
36
  export { configBackedAddressStore } from './address-store.ts';
30
37
  export { channelBackedPaymentNotifier } from './notifier.ts';
31
38
  export { createProviderBackedMerchantJudgeModel } from './merchant-judge.ts';
@@ -77,8 +77,8 @@
77
77
  * from a call that had material in its arguments.
78
78
  */
79
79
  import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
80
- import { MemoryCheckoutJournal, readDefaultCardId, readPaymentsEnabled, readPaymentsServiceConfig } from '@pellux/goodvibes-sdk/platform/payments';
81
- import type { CardMetadata, CheckoutJournal } from '@pellux/goodvibes-sdk/platform/payments';
80
+ import { readDefaultCardId, readPaymentsEnabled, readPaymentsServiceConfig } from '@pellux/goodvibes-sdk/platform/payments';
81
+ import type { CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
82
82
  import {
83
83
  registerPaymentsGatewayMethods,
84
84
  type GatewayMethodCatalog,
@@ -88,7 +88,7 @@ import {
88
88
  } from '../contracts.ts';
89
89
  import { HandlerError } from '../errors.ts';
90
90
  import { registerCatalogHandlers, type TypedHandler, type Unregister } from '../register.ts';
91
- import { CheckoutServiceHolder, checkoutBeginHandler, checkoutFillCardHandler, type CheckoutComposition } from './checkout-handlers.ts';
91
+ import { CheckoutServiceHolder, checkoutApproveHandler, checkoutBeginHandler, checkoutFillCardHandler, type CheckoutComposition } from './checkout-handlers.ts';
92
92
  import { CardStoreUnreadableError, type DaemonCardStore } from './card-store.ts';
93
93
  import { MAX_PURCHASE_LIST_LIMIT, type DaemonPurchaseLedger, type StoredPurchase } from './purchase-ledger.ts';
94
94
 
@@ -101,10 +101,65 @@ export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
101
101
  'payments.cards.create',
102
102
  'payments.cards.delete',
103
103
  'payments.purchases.list',
104
+ 'payments.checkout.approve',
104
105
  'payments.checkout.begin',
105
106
  'payments.checkout.fillCard',
106
107
  ];
107
108
 
109
+ /**
110
+ * The one descriptor in this family this PRODUCT authors, because the id is
111
+ * product-owned: the SDK's catalog holds the seven `payments.*` verbs it
112
+ * ships and no `payments.checkout.approve`, and the approve act is this
113
+ * daemon's own composition (its store, its confirmation gate, its wire
114
+ * shape). contracts.ts's never-author-a-descriptor rule is about not
115
+ * RE-declaring an SDK id, which this is not; the parity test
116
+ * (gateway-verb-family-parity.test.ts) pins this id the same way it pins the
117
+ * rest, so it cannot drift in silently.
118
+ */
119
+ const CHECKOUT_APPROVE_DESCRIPTOR: GatewayMethodDescriptor = {
120
+ id: 'payments.checkout.approve',
121
+ title: 'Approve One Purchase',
122
+ description:
123
+ 'Record that a human approves one specific purchase, out of band from the conversation that will run '
124
+ + 'it: the merchant\'s registrable domain, the item, and the amount (the same string a later begin call '
125
+ + 'passes as requestedMax). Mints a persisted, single-use approval bound to exactly those fields, '
126
+ + 'expiring in five minutes; payments.checkout.begin consumes it and refuses without one. Requires '
127
+ + 'confirm: true and the explicit-user-request context, the same confirmation gate every destructive '
128
+ + 'verb on this daemon uses. The response never carries card material; this verb never touches a card '
129
+ + 'at all. ws-only invoke verb; no REST binding: the gateway REST table is the daemon-sdk\'s and this '
130
+ + 'product cannot add rows to it, the same shape sessions.hosted.* already has.',
131
+ category: 'payments',
132
+ source: 'builtin',
133
+ access: 'admin',
134
+ transport: ['ws'],
135
+ scopes: ['write:payments'],
136
+ dangerous: true,
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ confirm: { type: 'boolean' },
141
+ merchantDomain: { type: 'string' },
142
+ item: { type: 'string' },
143
+ amount: { type: 'string' },
144
+ },
145
+ required: ['confirm', 'merchantDomain', 'item', 'amount'],
146
+ additionalProperties: false,
147
+ },
148
+ outputSchema: {
149
+ type: 'object',
150
+ properties: {
151
+ approved: { type: 'boolean' },
152
+ action: { type: 'string' },
153
+ merchantDomain: { type: 'string' },
154
+ item: { type: 'string' },
155
+ amount: { type: 'string' },
156
+ expiresAt: { type: 'string' },
157
+ },
158
+ required: ['approved', 'action', 'merchantDomain', 'item', 'amount', 'expiresAt'],
159
+ additionalProperties: false,
160
+ },
161
+ };
162
+
108
163
  /**
109
164
  * Kept, empty, rather than deleted: `gateway-payments-verbs.test.ts` and
110
165
  * `register.test.ts` iterate this to assert the unattached set, and an empty
@@ -378,8 +433,27 @@ function buildPaymentsGatewayService(deps: PaymentsHandlerDeps): PaymentsGateway
378
433
  // ---------------------------------------------------------------------------
379
434
 
380
435
  /**
381
- * Attach the seven `payments.*` handlers to the descriptors the SDK catalog
382
- * already holds. Returns the teardown.
436
+ * What `registerPaymentsMethods` returns now that attachment has an async
437
+ * phase. `unregister` is valid immediately, including before `ready` settles.
438
+ * `ready` resolves once every handler is attached and REJECTS if attaching the
439
+ * local handlers failed, so the caller that drops it must handle the
440
+ * rejection (daemon-handler-composition.ts logs it; tests await it).
441
+ */
442
+ export interface PaymentsRegistration {
443
+ readonly ready: Promise<void>;
444
+ readonly unregister: Unregister;
445
+ }
446
+
447
+ /**
448
+ * Attach the eight `payments.*` handlers: seven to the descriptors the SDK
449
+ * catalog already holds, and `payments.checkout.approve` to the one
450
+ * descriptor this product authors (see `CHECKOUT_APPROVE_DESCRIPTOR` above).
451
+ *
452
+ * The SDK's `registerPaymentsGatewayMethods` runs its boot recovery sweep
453
+ * before attaching, so it returns a promise and attaches a beat after this
454
+ * function returns. This module's five local handlers MUST attach after that,
455
+ * or the SDK's deferred attach would replace them, so they are chained on the
456
+ * SDK's promise and `ready` is how a caller observes the whole sequence.
383
457
  *
384
458
  * NOT gated on `payments.enabled`. That key defaults to false, and
385
459
  * `payments.cards.*` is how a surface CONFIGURES the capability, so gating
@@ -392,7 +466,7 @@ function buildPaymentsGatewayService(deps: PaymentsHandlerDeps): PaymentsGateway
392
466
  export function registerPaymentsMethods(
393
467
  catalog: GatewayMethodCatalog,
394
468
  deps: PaymentsHandlerDeps,
395
- ): Unregister {
469
+ ): PaymentsRegistration {
396
470
  // Every descriptor this module attaches to, captured BEFORE any
397
471
  // registration runs. `registerCatalogHandlers`' own teardown (used below for
398
472
  // four of these) removes the DESCRIPTOR from the catalog entirely rather
@@ -410,34 +484,39 @@ export function registerPaymentsMethods(
410
484
 
411
485
  const service = buildPaymentsGatewayService(deps);
412
486
 
413
- // Attaches all seven `payments.*` descriptors. budget/list/delete stay
414
- // attached through this; create/purchases-list/checkout-begin/checkout-
415
- // fillCard are all transiently attached here and immediately replaced below
416
- // with this daemon's own local handlers. See the header comment for why
417
- // each group needs its own wrapper.
418
- registerPaymentsGatewayMethods(catalog, service);
487
+ // Attaches all seven `payments.*` descriptors once its boot recovery sweep
488
+ // finishes. budget/list/delete stay attached through this; create/purchases-
489
+ // list/checkout-begin/checkout-fillCard are transiently attached and then
490
+ // replaced with this daemon's own local handlers in the chain below. The
491
+ // hook payloads are the SDK's designed audit records: the failure callback
492
+ // never carries a notice body and the sweep envelope exists to be logged.
493
+ const sdkAttach = registerPaymentsGatewayMethods(catalog, service, {
494
+ onRecoveryFailure: (error) => {
495
+ console.error('payments boot recovery failed', { error });
496
+ },
497
+ onRecoverySettled: (sweep) => {
498
+ console.info('payments boot recovery settled', { sweep });
499
+ },
500
+ });
419
501
 
420
502
  // The journal backing this registration's checkout pair's in-flight
421
503
  // registry (the SDK's own `CheckoutRegistry`, built inside
422
504
  // `PaymentsGatewayServiceImpl`'s constructor from whatever `CheckoutJournal`
423
- // it is handed, see checkout-handlers.ts's `buildCheckoutService`; this
424
- // value here is the journal, not the registry itself). `MemoryCheckoutJournal`
425
- // (the sdk's own, checkout-registry.ts, documented "durable across nothing")
426
- // is a deliberate choice for THIS pass, not an oversight: a durable journal
427
- // is real work (a file or store write on every `registry.advance`, including
428
- // the `submit-pending` flush checkout-flow.ts's step 9 makes right before the
429
- // merchant submit) that has not been done yet. The gap it leaves is exactly
430
- // the one that flush exists to close: if this process crashes between that
431
- // `submit-pending` write and seeing the merchant's response, a restart with a
432
- // durable journal could tell the owner "this purchase may already have been
433
- // submitted, do not resubmit it"; with this in-memory journal, that record is
434
- // gone the moment the process is, and a restart has no way to know the
435
- // purchase was ever in flight at all. Recorded as future work in
436
- // `.goodvibes/memory/decisions.json`, not implied to be solved here.
437
- const checkoutJournal: CheckoutJournal = new MemoryCheckoutJournal();
438
- // The ONE checkout service instance this registration's begin/fillCard pair
439
- // share for their whole life; see checkout-handlers.ts's own header.
440
- const checkoutServiceHolder = new CheckoutServiceHolder(deps, checkoutJournal);
505
+ // it is handed, see checkout-handlers.ts's `buildCheckoutService`). It comes
506
+ // from the composition (`deps.checkout.journal`), which in the real daemon
507
+ // is `DurableCheckoutJournal` (checkout-journal-store.ts): every phase write
508
+ // the registry makes, including the `submit-pending` flush checkout-flow.ts's
509
+ // step 9 issues right before the merchant submit, lands on disk before the
510
+ // submit happens, so a restart after a crash in that window can tell the
511
+ // owner "this purchase may already have been submitted, do not resubmit it"
512
+ // instead of having no record the purchase was ever in flight. Tests compose
513
+ // the SDK's `MemoryCheckoutJournal` here instead, which is what the seam in
514
+ // `CheckoutComposition` is for.
515
+ //
516
+ // The ONE checkout service instance this registration's approve/begin/
517
+ // fillCard verbs share for their whole life; see checkout-handlers.ts's own
518
+ // header.
519
+ const checkoutServiceHolder = new CheckoutServiceHolder(deps, deps.checkout.journal);
441
520
 
442
521
  const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
443
522
  const params = asRecord(body);
@@ -492,27 +571,61 @@ export function registerPaymentsMethods(
492
571
  });
493
572
  };
494
573
 
495
- const localTeardown = registerCatalogHandlers(catalog, [
496
- { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
497
- { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
498
- { id: 'payments.checkout.begin', handler: checkoutBeginHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
499
- { id: 'payments.checkout.fillCard', handler: checkoutFillCardHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
500
- ]);
501
-
502
- return () => {
503
- localTeardown();
504
- // Restores EVERY descriptor this module attached to, handler-less, not
505
- // only the three `registerPaymentsGatewayMethods` still holds a live
506
- // handler on. The other four had their descriptor removed outright by
507
- // `localTeardown()` above (see the `descriptors` capture at the top of
508
- // this function for why), so without this a re-registration on the SAME
509
- // catalog (a second `registerPaymentsMethods` call, as a restart-without-
510
- // recompose test does) would find those four ids gone from the catalog
511
- // and throw `METHOD_NOT_FOUND` trying to attach to them, rather than
512
- // finding the SDK's own builtin descriptor there to replace, exactly as
513
- // it would on a catalog this module had never touched.
574
+ // Restores EVERY descriptor this module attached to, handler-less, not
575
+ // only the three `registerPaymentsGatewayMethods` still holds a live
576
+ // handler on. The other four have their descriptor removed outright by
577
+ // `localTeardown()` (see the `descriptors` capture at the top of this
578
+ // function for why), so without this a re-registration on the SAME catalog
579
+ // (a second `registerPaymentsMethods` call, as a restart-without-recompose
580
+ // test does) would find those four ids gone from the catalog and throw
581
+ // `METHOD_NOT_FOUND` trying to attach to them, rather than finding the
582
+ // SDK's own builtin descriptor there to replace, exactly as it would on a
583
+ // catalog this module had never touched.
584
+ const restoreDescriptors = (): void => {
514
585
  for (const [, descriptor] of descriptors) {
515
586
  catalog.register(descriptor, undefined, { replace: true });
516
587
  }
517
588
  };
589
+
590
+ let torn = false;
591
+ let localTeardown: Unregister | undefined;
592
+
593
+ const ready = sdkAttach.then(() => {
594
+ // Teardown already ran: the SDK's attach (which resolved just before this
595
+ // callback) put live handlers back on descriptors the teardown had
596
+ // restored handler-less, so restore them again instead of attaching the
597
+ // local handlers to a surface that was already released.
598
+ if (torn) {
599
+ restoreDescriptors();
600
+ return;
601
+ }
602
+ // The approve descriptor is product-authored (see its declaration above),
603
+ // so it is placed on the catalog here, handler-less, exactly where the
604
+ // SDK's own descriptors already sit, and then attached through the same
605
+ // `registerCatalogHandlers` path as the other local wrappers. `replace:
606
+ // true` so a registration over a catalog that already carries it (a
607
+ // recompose that skipped teardown) replaces rather than throws.
608
+ catalog.register(CHECKOUT_APPROVE_DESCRIPTOR, undefined, { replace: true });
609
+
610
+ localTeardown = registerCatalogHandlers(catalog, [
611
+ { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
612
+ { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
613
+ // The confirmation gate (`confirm: true` AND the explicit-user-request
614
+ // context) is what makes this verb owner-direct: the handler then passes
615
+ // `surface: 'owner-direct'` from its own code path. See
616
+ // checkout-handlers.ts's `checkoutApproveHandler`.
617
+ { id: 'payments.checkout.approve', handler: checkoutApproveHandler(deps) as TypedHandler<unknown, unknown>, options: { confirm: true } },
618
+ { id: 'payments.checkout.begin', handler: checkoutBeginHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
619
+ { id: 'payments.checkout.fillCard', handler: checkoutFillCardHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
620
+ ]);
621
+ });
622
+
623
+ return {
624
+ ready,
625
+ unregister: () => {
626
+ torn = true;
627
+ localTeardown?.();
628
+ restoreDescriptors();
629
+ },
630
+ };
518
631
  }
@@ -92,7 +92,8 @@ export function createDaemonHandlerComposition(
92
92
  // provider only carries the teardown. See payments-composition.ts for the
93
93
  // full checkout composition (address store, notifier, merchant judge,
94
94
  // browser-checkout seam).
95
- registerPayments: () => createPaymentsServices({
95
+ registerPayments: () => {
96
+ const payments = createPaymentsServices({
96
97
  gatewayMethods: options.gatewayMethods,
97
98
  configManager: options.configManager,
98
99
  secretsManager: options.secretsManager,
@@ -127,7 +128,15 @@ export function createDaemonHandlerComposition(
127
128
  checkoutSeam: options.checkoutSeam,
128
129
  channelDeliveryRouter: options.channelDeliveryRouter,
129
130
  providerRegistry: options.providerRegistry,
130
- }).unregister,
131
+ });
132
+ // The payments verbs attach a beat after composition (the SDK's boot
133
+ // recovery sweep runs first); this composition stays synchronous, so
134
+ // the rejection path is handled here rather than awaited.
135
+ payments.ready.catch((error) => {
136
+ handlerLogger.error('payments handler attach failed', { error });
137
+ });
138
+ return payments.unregister;
139
+ },
131
140
  registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
132
141
  });
133
142
  }
@@ -162,7 +162,7 @@ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServi
162
162
  workingDirectory,
163
163
  command: params.binaryPath,
164
164
  // `--daemon-home` names the daemon's own STATE directory, the one holding
165
- // operator-tokens.json, auth-users.json and daemon-settings.json, which is
165
+ // operator-tokens.json and the daemon tier's settings.json, which is
166
166
  // `<home>/.goodvibes/daemon`. This baked the USER HOME, so a serviced
167
167
  // daemon filed its identity a level above where every reader in this
168
168
  // repository looks: the SDK's platform/config goodvibes-home resolves the flag AS the state
@@ -80,9 +80,11 @@ import type { ChannelDeliveryRouter } from '@pellux/goodvibes-sdk/platform/chann
80
80
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
81
81
  import type { ShellPathService } from '@/runtime/index.ts';
82
82
  import {
83
+ DaemonApprovalStore,
83
84
  DaemonCardStore,
84
85
  DaemonPurchaseLedger,
85
86
  DurableBudgetLedger,
87
+ DurableCheckoutJournal,
86
88
  channelBackedPaymentNotifier,
87
89
  configBackedAddressStore,
88
90
  createProviderBackedMerchantJudgeModel,
@@ -118,6 +120,13 @@ export interface PaymentsServices {
118
120
  readonly cards: DaemonCardStore;
119
121
  readonly purchases: DaemonPurchaseLedger;
120
122
  readonly budget: BudgetLedger;
123
+ /**
124
+ * Resolves once every payments verb is attached; the SDK runs its boot
125
+ * recovery sweep first, so attachment lands a beat after composition.
126
+ * Rejects if attaching the daemon's local handlers failed, so whoever drops
127
+ * this promise must handle that (daemon-handler-composition.ts logs it).
128
+ */
129
+ readonly ready: Promise<void>;
121
130
  /** Detaches the handlers. Held by the runtime disposal scope. */
122
131
  readonly unregister: () => void;
123
132
  }
@@ -174,8 +183,22 @@ export function createPaymentsServices(options: PaymentsCompositionOptions): Pay
174
183
  notifier: channelBackedPaymentNotifier(config, options.channelDeliveryRouter),
175
184
  merchantJudge: createModelMerchantJudge(createProviderBackedMerchantJudgeModel(options.providerRegistry)),
176
185
  untrusted: getProcessUntrustedContentLedger(),
186
+ // The persisted owner approvals `payments.checkout.approve` mints and
187
+ // `begin` spends, beside the other payments stores. Constructing it reads
188
+ // the file once, synchronously, the same one-touch boot cost the budget
189
+ // ledger already pays.
190
+ approvals: new DaemonApprovalStore(
191
+ controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-approvals.json'),
192
+ ),
193
+ // The durable in-flight checkout journal. Every phase write the registry
194
+ // makes, the `submit-pending` flush before the merchant submit included,
195
+ // lands here before the flow proceeds, so a crash in the one ambiguous
196
+ // window leaves a record a restart can disclose instead of nothing.
197
+ journal: new DurableCheckoutJournal(
198
+ controlPlaneStorePath(options.shellPaths, GOODVIBES_DAEMON_SURFACE_ROOT, 'payments-checkout-journal.json'),
199
+ ),
177
200
  };
178
- const unregister = registerPaymentsMethods(options.gatewayMethods, {
201
+ const registration = registerPaymentsMethods(options.gatewayMethods, {
179
202
  cards,
180
203
  purchases,
181
204
  budget,
@@ -183,5 +206,5 @@ export function createPaymentsServices(options: PaymentsCompositionOptions): Pay
183
206
  isPaymentsLeader: options.isPaymentsLeader,
184
207
  checkout,
185
208
  });
186
- return { cards, purchases, budget, unregister };
209
+ return { cards, purchases, budget, ready: registration.ready, unregister: registration.unregister };
187
210
  }