@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 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
  [![CI](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-daemon/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.28.14-blue.svg)](https://github.com/mgd34msu/goodvibes-daemon)
6
-
7
- The GoodVibes daemon: one long-running process per machine that holds the control plane every
8
- GoodVibes client talks to. It answers the operator verb families over HTTP, reads and replies on
9
- your channels, elects a leader among the machines you have grouped together so only one of them
10
- answers a shared inbox, runs scheduled and triggered work, keeps the session, memory, knowledge
11
- and code-index stores, provisions the local voice and wake-word models, and updates itself at an
12
- idle moment with a rollback if the new binary will not start.
5
+ [![Version](https://img.shields.io/badge/version-1.28.21-blue.svg)](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 handlers the SDK does not own (inbox, triage, drafts, routing, remote peers,
24
- credentials),
25
- - the CLI (`send`, `cluster`, `webui`, `provision-wake-model`, `install-service` and friends),
30
+ - the product handler families the SDK does not own, tabled below,
31
+ - the CLI, every command of which is documented in
32
+ [docs/commands-reference.md](docs/commands-reference.md),
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's version is **1.28.0**. Live installs already carry a settings reader-floor
54
+ The daemon versions on the continuous **1.28** line; this release is **1.28.21**. Live
55
+ installs already carry a settings reader-floor
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.20",
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.18",
71
- "@pellux/goodvibes-terminal-shell": "2.0.18",
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.18",
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, auth users, daemon settings).",
137
+ summary: "The daemon's own identity directory (operator tokens, daemon settings).",
138
138
  },
139
139
  {
140
140
  tokens: ['--working-dir', '--cd', '-C'],
@@ -554,16 +554,17 @@ export const DAEMON_COMMANDS: readonly DaemonCommandSpec[] = [
554
554
  'updates and restarts, the version an automatic rollback rejected (if any), and',
555
555
  'whether a rollback is currently in force.',
556
556
  '',
557
- '--check asks the daemon to look for a new release now. The daemon checks hourly',
558
- 'on its own and swaps only at an idle moment; this command exists for the case',
559
- 'where you do not want to wait for the next hour.',
557
+ '--check is honest about a gap: the control plane publishes no verb to trigger',
558
+ 'an update check early, so it says so plainly and names the two things that do',
559
+ 'work: waiting for the hourly check, or restarting the service, which checks on',
560
+ 'the way up.',
560
561
  '',
561
562
  'The rollback and receipt lines are read from files the daemon writes on its own',
562
563
  'host, so they are reported for a local daemon and named as unavailable for a',
563
564
  'remote one.',
564
565
  ],
565
566
  flags: [
566
- { tokens: ['--check'], field: 'check', kind: 'boolean', summary: 'Ask for an update check now instead of waiting for the hourly one.' },
567
+ { tokens: ['--check'], field: 'check', kind: 'boolean', summary: 'Explain that no early-check verb exists, and name the two paths that work.' },
567
568
  JSON_FLAG,
568
569
  ...REMOTE_TARGET_FLAGS,
569
570
  ],
package/src/daemon/cli.ts CHANGED
@@ -74,7 +74,7 @@ type DaemonCliOwnership = {
74
74
  readonly workingDirectory: string;
75
75
  /** The GoodVibes tree root, settings, workspace, and discovery all hang off this. */
76
76
  readonly homeDirectory: string;
77
- /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
77
+ /** The daemon's OWN identity home (operator tokens, daemon settings). */
78
78
  readonly daemonHomeDirectory: string;
79
79
  /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override, see goodvibes-home.ts. */
80
80
  readonly isOverridden: boolean;
@@ -91,7 +91,7 @@ type DaemonCliTokens = {
91
91
  * Two different directories that used to be one.
92
92
  *
93
93
  * `GOODVIBES_DAEMON_HOME` names the DAEMON's home, the identity directory
94
- * holding auth-users.json, operator-tokens.json, and daemon-settings.json. That
94
+ * holding operator-tokens.json and the daemon tier's settings.json. That
95
95
  * is what the name says and what the SDK's `resolveDaemonHomeDir()` has always
96
96
  * meant by it. This function used to read it as the GoodVibes tree ROOT, so
97
97
  * setting it relocated settings, workspace, and every discovery root as well,
@@ -372,7 +372,7 @@ async function main(): Promise<void> {
372
372
  // derivation: `daemonHomeDirectory` is this process's own resolution of
373
373
  // `--daemon-home`/`GOODVIBES_DAEMON_HOME`, and the two must agree so the
374
374
  // daemon's config reads and writes the same file its identity state
375
- // (operator-tokens.json, auth-users.json) already lives beside.
375
+ // (operator-tokens.json) already lives beside.
376
376
  const config = new ConfigManager({
377
377
  workingDir,
378
378
  homeDir: homeDirectory,
@@ -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
+ }