@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 +30 -0
- package/README.md +24 -8
- package/package.json +3 -3
- package/src/cli/command-catalog.ts +6 -5
- package/src/daemon/cli.ts +3 -3
- package/src/daemon/handlers/payments/approval-store.ts +275 -0
- package/src/daemon/handlers/payments/budget-store.ts +3 -2
- package/src/daemon/handlers/payments/checkout-handlers.ts +174 -22
- package/src/daemon/handlers/payments/checkout-journal-store.ts +162 -0
- package/src/daemon/handlers/payments/index.ts +7 -0
- package/src/daemon/handlers/payments/register.ts +162 -49
- package/src/runtime/daemon-handler-composition.ts +11 -2
- package/src/runtime/legacy-daemon-migration.ts +1 -1
- package/src/runtime/payments-composition.ts +25 -2
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
|
[](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-daemon)
|
|
6
6
|
|
|
7
|
-
The GoodVibes daemon
|
|
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
|
|
29
|
-
|
|
30
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
71
|
-
"@pellux/goodvibes-terminal-shell": "2.0.
|
|
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,
|
|
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
|
|
558
|
-
'
|
|
559
|
-
'
|
|
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: '
|
|
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 (
|
|
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
|
|
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
|
|
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:
|
|
181
|
-
// (
|
|
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
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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 {
|
|
81
|
-
import type { CardMetadata
|
|
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
|
-
*
|
|
382
|
-
*
|
|
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
|
-
):
|
|
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
|
|
414
|
-
// attached through this; create/purchases-
|
|
415
|
-
// fillCard are
|
|
416
|
-
// with this daemon's own local handlers
|
|
417
|
-
//
|
|
418
|
-
|
|
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
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
// `
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
const
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|