@pellux/goodvibes-daemon 1.28.20 → 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 +64 -0
- package/README.md +34 -13
- package/package.json +4 -4
- package/src/cli/command-catalog.ts +6 -5
- package/src/daemon/cli.ts +3 -3
- package/src/daemon/handlers/contracts.ts +15 -0
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/payments/address-store.ts +54 -0
- package/src/daemon/handlers/payments/approval-store.ts +275 -0
- package/src/daemon/handlers/payments/budget-store.ts +357 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +678 -0
- package/src/daemon/handlers/payments/checkout-journal-store.ts +162 -0
- package/src/daemon/handlers/payments/index.ts +14 -1
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/register.ts +376 -136
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +39 -13
- package/src/runtime/legacy-daemon-migration.ts +1 -1
- package/src/runtime/payments-composition.ts +95 -28
- package/src/runtime/services.ts +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
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
|
+
|
|
35
|
+
## [1.28.21] - 2026-08-21
|
|
36
|
+
|
|
37
|
+
### Changes
|
|
38
|
+
|
|
39
|
+
- **The checkout verbs answer.** `payments.checkout.begin` and
|
|
40
|
+
`payments.checkout.fillCard` attach through sdk 2.0.19's browser-checkout
|
|
41
|
+
seam: one payments service per registration holds the in-flight checkout
|
|
42
|
+
registry across both verbs, a second begin on a busy page refuses, and the
|
|
43
|
+
card-material guard is the engine's own instance end to end. Without a
|
|
44
|
+
merchant-response reader wired, purchase outcomes record honestly as
|
|
45
|
+
`submitted-unverified` and the owner report says so. `explicitUserRequest`
|
|
46
|
+
gates entry to `begin` and is documented as what it is: a caller assertion,
|
|
47
|
+
no stronger than a confirm flag; the money controls are the budget ledger,
|
|
48
|
+
the purchase notices and decision windows, and the card-material guard.
|
|
49
|
+
- **The daily budget survives restarts.** Reservations and commits persist
|
|
50
|
+
atomically to `payments-budget.json` and reload at boot; corrupt or
|
|
51
|
+
malformed entries (including non-finite or out-of-range amounts and
|
|
52
|
+
timestamps) are dropped with a warning instead of poisoning the pools; a
|
|
53
|
+
commit whose disk write fails keeps the in-memory spend, logs the exposure
|
|
54
|
+
plainly, and retries on the next mutation, so a full disk cannot erase a
|
|
55
|
+
spend that already happened at the merchant; a failed reserve rolls back
|
|
56
|
+
rather than holding budget; old spend records prune at boot; loaded
|
|
57
|
+
reservations are disclosed at startup for reconciliation.
|
|
58
|
+
- **Five payments verbs ride the sdk registrar.** `budget.status`,
|
|
59
|
+
`cards.list` and `cards.delete` attach through sdk 2.0.19's
|
|
60
|
+
`registerPaymentsGatewayMethods`; `cards.create` and `purchases.list` keep
|
|
61
|
+
thin local wrappers over the same service for stricter field-named
|
|
62
|
+
validation and query-string limits. Wire behavior for all five is pinned
|
|
63
|
+
byte-identical by the contract suites. Teardown restores all seven
|
|
64
|
+
descriptors to their pristine builtin state.
|
|
65
|
+
- Platform runtime 2.0.19: the pin rides the checkout-seam and wake-fix
|
|
66
|
+
cycle; this daemon serves the repaired `voice.wake.model.get`, which fixes
|
|
67
|
+
wake-word model delivery for every surface that fetches models from it.
|
|
68
|
+
|
|
5
69
|
---
|
|
6
70
|
|
|
7
71
|
## [1.28.20] - 2026-08-21
|
package/README.md
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
10
|
+
- answers the operator verb families over HTTP
|
|
11
|
+
- reads and replies on your channels
|
|
12
|
+
- elects a leader among the machines you have grouped together, so only one of them answers a
|
|
13
|
+
shared inbox
|
|
14
|
+
- runs scheduled and triggered work
|
|
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
|
|
18
|
+
- provisions the local voice and wake-word models
|
|
19
|
+
- updates itself at an idle moment, with a rollback if the new binary will not start
|
|
13
20
|
|
|
14
21
|
The terminal app (`goodvibes`), the conversational agent (`goodvibes-agent`) and the web app are
|
|
15
22
|
clients of this process. They render, they capture input, and they call verbs; the work happens
|
|
@@ -20,11 +27,23 @@ here.
|
|
|
20
27
|
A **product** over `@pellux/goodvibes-sdk`, exactly like the TUI and the agent are:
|
|
21
28
|
|
|
22
29
|
- the composition root that builds the daemon's service graph,
|
|
23
|
-
- the product
|
|
24
|
-
|
|
25
|
-
-
|
|
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),
|
|
26
33
|
- packaging: the compiled `goodvibes-daemon-<os>-<arch>` binaries.
|
|
27
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
|
+
|
|
28
47
|
Every engine (the facade, the routes, the brokers, the updater, the channel adapters, the
|
|
29
48
|
schedulers) lives in the SDK and is consumed from the published package. Nothing was moved out of
|
|
30
49
|
the SDK to build this repository, and nothing should be: a capability that both a client and the
|
|
@@ -32,7 +51,8 @@ daemon need belongs in the SDK, not here.
|
|
|
32
51
|
|
|
33
52
|
## Version line
|
|
34
53
|
|
|
35
|
-
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
|
|
36
56
|
(`$goodvibes.minReaderVersion`), the update handover compares versions monotonically, and the
|
|
37
57
|
rejected-version record is keyed by version. Those three mechanics all depend on the version
|
|
38
58
|
line staying continuous and monotonically increasing.
|
|
@@ -45,7 +65,8 @@ curl -fsSL https://goodvibes.sh/install.sh | sh
|
|
|
45
65
|
|
|
46
66
|
This installs the whole GoodVibes suite (the daemon, the terminal app, the
|
|
47
67
|
agent, and the browser operator surface) from checksum-verified binaries,
|
|
48
|
-
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).
|
|
49
70
|
|
|
50
71
|
The browser surface is not a fourth binary and not a fourth service: the bundle
|
|
51
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",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"web-tree-sitter": "^0.26.7"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@pellux/goodvibes-toolchain": "2.0.
|
|
92
|
+
"@pellux/goodvibes-toolchain": "2.0.19",
|
|
93
93
|
"@types/bun": "^1.3.10",
|
|
94
94
|
"typescript": "^5.9.3"
|
|
95
95
|
},
|
|
@@ -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,
|
|
@@ -17,6 +17,21 @@ export type {
|
|
|
17
17
|
GatewayMethodHandler,
|
|
18
18
|
} from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
19
19
|
|
|
20
|
+
// The SDK's own `payments.*` route module: the registrar and the service seam
|
|
21
|
+
// it dispatches to. Exported by the SDK barrel as of 2.0.18 (CHANGELOG). Three
|
|
22
|
+
// of the seven verbs attach through this unchanged; see
|
|
23
|
+
// daemon/handlers/payments/register.ts and checkout-handlers.ts for why the
|
|
24
|
+
// other four (`cards.create`, `purchases.list`, `checkout.begin`,
|
|
25
|
+
// `checkout.fillCard`) stay local wrappers instead.
|
|
26
|
+
export { registerPaymentsGatewayMethods } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
27
|
+
export type { PaymentsGatewayService, PaymentPurchaseView } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
28
|
+
|
|
29
|
+
// The browser-checkout seam a daemon composition receives through
|
|
30
|
+
// `onBrowserCheckout` (sdk 2.0.19, platform/control-plane's
|
|
31
|
+
// `composeDaemonBrowser`). See daemon/handlers/payments/register.ts for how
|
|
32
|
+
// the checkout pair reads it.
|
|
33
|
+
export type { BrowserCheckoutSeam } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
34
|
+
|
|
20
35
|
// Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
|
|
21
36
|
export type {
|
|
22
37
|
ChannelIdentity,
|
|
@@ -57,7 +57,7 @@ export interface DaemonHandlerSurfaceProviders {
|
|
|
57
57
|
/** channels.drafts.* */
|
|
58
58
|
readonly registerDrafts: SurfaceRegister;
|
|
59
59
|
/**
|
|
60
|
-
* payments.budget.status / cards.* / purchases.list.
|
|
60
|
+
* payments.budget.status / cards.* / purchases.list / checkout.begin / checkout.fillCard.
|
|
61
61
|
*
|
|
62
62
|
* Its stores need a shell-path resolver and a scoped secret writer, neither of
|
|
63
63
|
* which is on `HandlerContext`, so the composition root builds them and hands
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* address-store.ts, the stored shipping and billing addresses, read from
|
|
3
|
+
* config.
|
|
4
|
+
*
|
|
5
|
+
* The SDK's `platform/config/schema-domain-payments.ts` already declares flat
|
|
6
|
+
* `payments.shippingAddress.*` / `payments.billingAddress.*` keys (name,
|
|
7
|
+
* line1, line2, city, region, postalCode, country each), settable through the
|
|
8
|
+
* owner profile and the settings surfaces; address.ts's own header names the
|
|
9
|
+
* defect this closes: "nothing in the checkout path read either." This is
|
|
10
|
+
* that read path, the daemon-owned counterpart to `DaemonCardStore` for the
|
|
11
|
+
* OTHER thing a checkout needs supplied rather than typed by the model.
|
|
12
|
+
*
|
|
13
|
+
* A field that is present but blank reads as absent (`''` is not a value any
|
|
14
|
+
* of these fields can honestly hold), so a half-set address is reported the
|
|
15
|
+
* same way a wholly-unset one is: `AddressStore.read` returns the address with
|
|
16
|
+
* whatever is there, and `checkAddress` (address.ts) is what decides whether
|
|
17
|
+
* that is complete enough to ship to, exactly as it already does for the SDK's
|
|
18
|
+
* own test doubles. Nothing here validates completeness; this module only
|
|
19
|
+
* reads what config holds.
|
|
20
|
+
*/
|
|
21
|
+
import type { AddressKind, AddressStore, PostalAddress, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
|
|
22
|
+
|
|
23
|
+
function readAddressField(config: PaymentsConfigReader, kind: AddressKind, field: string): string {
|
|
24
|
+
const value = config.get(`payments.${kind}Address.${field}`);
|
|
25
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every field blank means "nothing stored", read back as `null` rather than an all-empty address. */
|
|
29
|
+
function isEntirelyBlank(address: PostalAddress): boolean {
|
|
30
|
+
return address.name === ''
|
|
31
|
+
&& address.line1 === ''
|
|
32
|
+
&& address.line2 === ''
|
|
33
|
+
&& address.city === ''
|
|
34
|
+
&& address.region === ''
|
|
35
|
+
&& address.postalCode === ''
|
|
36
|
+
&& address.country === '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configBackedAddressStore(config: PaymentsConfigReader): AddressStore {
|
|
40
|
+
return {
|
|
41
|
+
async read(kind: AddressKind): Promise<PostalAddress | null> {
|
|
42
|
+
const address: PostalAddress = {
|
|
43
|
+
name: readAddressField(config, kind, 'name'),
|
|
44
|
+
line1: readAddressField(config, kind, 'line1'),
|
|
45
|
+
line2: readAddressField(config, kind, 'line2'),
|
|
46
|
+
city: readAddressField(config, kind, 'city'),
|
|
47
|
+
region: readAddressField(config, kind, 'region'),
|
|
48
|
+
postalCode: readAddressField(config, kind, 'postalCode'),
|
|
49
|
+
country: readAddressField(config, kind, 'country'),
|
|
50
|
+
};
|
|
51
|
+
return isEntirelyBlank(address) ? null : address;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|