@pellux/goodvibes-daemon 1.28.19 → 1.28.20
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 +85 -63
- package/README.md +13 -13
- package/bin/launcher-support.js +5 -5
- package/package.json +6 -4
- package/scripts/postinstall.js +8 -8
- package/src/cli/command-catalog.ts +22 -22
- package/src/cli/completion.ts +4 -4
- package/src/cli/help.ts +5 -5
- package/src/cli/index.ts +3 -3
- package/src/cli/parser.ts +2 -2
- package/src/cli/surface-catalog.ts +1 -1
- package/src/cli/types.ts +2 -2
- package/src/cluster/daemon-ws-call.ts +5 -5
- package/src/cluster/raw-reply-route.ts +5 -5
- package/src/config/checkpoint-settings.ts +7 -7
- package/src/config/config-key-guard.ts +22 -0
- package/src/config/run-daemon-config-migration.ts +3 -3
- package/src/config/secret-config.ts +7 -7
- package/src/config/surface.ts +3 -3
- package/src/core/pairing-banner.ts +5 -5
- package/src/daemon/cli.ts +45 -43
- package/src/daemon/config-command.ts +15 -15
- package/src/daemon/handlers/context.ts +1 -1
- package/src/daemon/handlers/contracts.ts +4 -4
- package/src/daemon/handlers/credentials.ts +1 -1
- package/src/daemon/handlers/drafts/draft-store.ts +3 -3
- package/src/daemon/handlers/drafts/register.ts +4 -4
- package/src/daemon/handlers/inbox/aggregator.ts +8 -8
- package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
- package/src/daemon/handlers/inbox/index.ts +7 -7
- package/src/daemon/handlers/inbox/mapping.ts +2 -2
- package/src/daemon/handlers/inbox/poller.ts +5 -5
- package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
- package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
- package/src/daemon/handlers/inbox/providers/email.ts +3 -3
- package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
- package/src/daemon/handlers/index.ts +18 -8
- package/src/daemon/handlers/payments/card-store.ts +486 -0
- package/src/daemon/handlers/payments/index.ts +32 -0
- package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
- package/src/daemon/handlers/payments/register.ts +391 -0
- package/src/daemon/handlers/register.ts +3 -3
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
- package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
- package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
- package/src/daemon/handlers/remote/backends/types.ts +2 -2
- package/src/daemon/handlers/remote/dispatcher.ts +3 -3
- package/src/daemon/handlers/remote/index.ts +1 -1
- package/src/daemon/handlers/remote/peer-registry.ts +62 -13
- package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
- package/src/daemon/handlers/routing/index.ts +1 -1
- package/src/daemon/handlers/routing/route-store.ts +1 -1
- package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
- package/src/daemon/handlers/sqlite-store.ts +9 -9
- package/src/daemon/handlers/triage/index.ts +1 -1
- package/src/daemon/handlers/triage/integration.ts +3 -3
- package/src/daemon/handlers/triage/pipeline.ts +2 -2
- package/src/daemon/handlers/triage/scorer.ts +2 -2
- package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
- package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
- package/src/daemon/handlers/triage/tagger/index.ts +1 -1
- package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
- package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
- package/src/daemon/handlers/triage/types.ts +2 -2
- package/src/daemon/lifecycle.ts +5 -5
- package/src/daemon/local-daemon-state.ts +7 -7
- package/src/daemon/pair-command.ts +14 -14
- package/src/daemon/provision-wake-model.ts +5 -5
- package/src/daemon/send/channels.ts +7 -7
- package/src/daemon/send/command.ts +11 -11
- package/src/daemon/send/composition.ts +5 -5
- package/src/daemon/send/failure-text.ts +6 -6
- package/src/daemon/send/inert-text.ts +18 -18
- package/src/daemon/send/stdin.ts +3 -3
- package/src/daemon/service-commands.ts +32 -32
- package/src/daemon/sessions-command.ts +7 -7
- package/src/daemon/status-command.ts +22 -22
- package/src/daemon/webui-command.ts +14 -14
- package/src/runtime/boot-tasks.ts +1 -1
- package/src/runtime/cluster-composition.ts +9 -9
- package/src/runtime/cluster-group-composition.ts +7 -7
- package/src/runtime/conversation-rewind-port.ts +8 -8
- package/src/runtime/credential-composition.ts +2 -2
- package/src/runtime/daemon-handler-composition.ts +44 -4
- package/src/runtime/device-posture-composition.ts +10 -10
- package/src/runtime/disposal-wiring.ts +8 -8
- package/src/runtime/fleet-needs-input-push.ts +4 -4
- package/src/runtime/fleet-services.ts +1 -1
- package/src/runtime/hosted-session-composition.ts +13 -13
- package/src/runtime/index.ts +1 -1
- package/src/runtime/knowledge-services.ts +2 -2
- package/src/runtime/legacy-daemon-migration.ts +43 -43
- package/src/runtime/legacy-daemon-reconcile.ts +30 -30
- package/src/runtime/mail-composition.ts +6 -6
- package/src/runtime/notification-dispatch.ts +7 -7
- package/src/runtime/payments-composition.ts +143 -0
- package/src/runtime/plugin-composition.ts +7 -7
- package/src/runtime/runtime-services-types.ts +9 -9
- package/src/runtime/services.ts +29 -27
- package/src/runtime/trigger-services.ts +1 -1
- package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
- package/src/runtime/trust/trust-gated-approvals.ts +9 -9
- package/src/runtime/update-check.ts +4 -4
- package/src/runtime/workspace-checkpointing.ts +6 -6
- package/src/testing/daemon-fixture.ts +11 -11
- package/src/testing/hosted-session-failures.ts +4 -4
- package/src/version.ts +2 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* register.ts, the `payments.*` handlers this daemon attaches.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the handlers are written here and not imported ────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The SDK has these handlers already: `registerPaymentsGatewayMethods` in
|
|
7
|
+
* `platform/control-plane/routes/payments.ts`. It cannot be called from here.
|
|
8
|
+
* That module is not re-exported by `platform/control-plane/index.ts`, and the
|
|
9
|
+
* SDK package's `exports` map publishes only the barrel, so the symbol exists in
|
|
10
|
+
* the installed `dist` and no import path reaches it. `registerGatewayVerbGroups`
|
|
11
|
+
* (the SDK's own composition entry, which the terminal-shell wrapper calls from
|
|
12
|
+
* runtime/services.ts) carries no payments dependency either, so there is no
|
|
13
|
+
* argument this daemon can pass that would make the SDK attach them.
|
|
14
|
+
*
|
|
15
|
+
* So this follows the idiom this repository already uses for every other family
|
|
16
|
+
* it serves, stated at the top of handlers/index.ts and implemented by
|
|
17
|
+
* `registerCatalogHandler`: the SDK owns the id, the descriptor, the schemas,
|
|
18
|
+
* the scopes and the access level, and only the BEHAVIOUR is ours. Nothing
|
|
19
|
+
* below authors a descriptor.
|
|
20
|
+
*
|
|
21
|
+
* The right long-term fix is one line in the SDK's control-plane barrel. Until
|
|
22
|
+
* that lands, the choice is these handlers or a 501 on every payments verb, and
|
|
23
|
+
* a 501 is what the webui and the desktop app have been getting.
|
|
24
|
+
*
|
|
25
|
+
* ── What is attached, and what deliberately is not ────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* Attached: budget.status, cards.list, cards.create, cards.delete,
|
|
28
|
+
* purchases.list. Every one of them is answerable from stores this daemon owns.
|
|
29
|
+
*
|
|
30
|
+
* NOT attached: checkout.begin and checkout.fillCard. Both need a
|
|
31
|
+
* `CheckoutPageDriver` bound to an open browser page, and this composition
|
|
32
|
+
* cannot produce one: `createDaemonBrowserGatewayService` builds the engine
|
|
33
|
+
* inside `registerGatewayVerbGroups` and returns only the `BrowserGatewayService`
|
|
34
|
+
* slice, which exposes no page handle and no `fillSecret`; and that engine is
|
|
35
|
+
* constructed with no `cardFieldGuard`, so its secret-fill path refuses by
|
|
36
|
+
* design. Both verbs therefore keep answering 501 NOT_INVOKABLE, which is the
|
|
37
|
+
* honest answer for a capability nothing here can perform, and a better one than
|
|
38
|
+
* a handler that accepts the call and fails inside.
|
|
39
|
+
*
|
|
40
|
+
* ── Containment ───────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* Every response below is BUILT from named fields rather than spread from a
|
|
43
|
+
* store record, for the reason the SDK's own route module gives: an allowlist
|
|
44
|
+
* silently drops a field a later change adds, a denylist silently ships it, and
|
|
45
|
+
* for anything on a card's code path that is the correct direction to fail.
|
|
46
|
+
* No handler here reads card material, and no failure path forwards a message
|
|
47
|
+
* from a call that had material in its arguments.
|
|
48
|
+
*/
|
|
49
|
+
import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
|
|
50
|
+
import {
|
|
51
|
+
readDefaultCardId,
|
|
52
|
+
readPaymentsEnabled,
|
|
53
|
+
readPaymentsServiceConfig,
|
|
54
|
+
} from '@pellux/goodvibes-sdk/platform/payments';
|
|
55
|
+
import type { CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
|
|
56
|
+
import type { GatewayMethodCatalog } from '../contracts.ts';
|
|
57
|
+
import { HandlerError } from '../errors.ts';
|
|
58
|
+
import { registerCatalogHandlers, type TypedHandler, type Unregister } from '../register.ts';
|
|
59
|
+
import { CardStoreUnreadableError, type DaemonCardStore } from './card-store.ts';
|
|
60
|
+
import { MAX_PURCHASE_LIST_LIMIT, type DaemonPurchaseLedger, type StoredPurchase } from './purchase-ledger.ts';
|
|
61
|
+
|
|
62
|
+
/** The verbs this module attaches. Named so a test can assert the exact set. */
|
|
63
|
+
export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
|
|
64
|
+
'payments.budget.status',
|
|
65
|
+
'payments.cards.list',
|
|
66
|
+
'payments.cards.create',
|
|
67
|
+
'payments.cards.delete',
|
|
68
|
+
'payments.purchases.list',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The verbs this composition leaves unattached, and the reason each one is.
|
|
73
|
+
*
|
|
74
|
+
* Exported so the refusal is testable: a change that wires a page driver has to
|
|
75
|
+
* delete the entry, and a change that attaches one of these without wiring a
|
|
76
|
+
* driver fails the same assertion.
|
|
77
|
+
*/
|
|
78
|
+
export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [
|
|
79
|
+
{
|
|
80
|
+
id: 'payments.checkout.begin',
|
|
81
|
+
reason:
|
|
82
|
+
'Needs a CheckoutPageDriver for an open browser page. The SDK builds the browser engine inside '
|
|
83
|
+
+ 'registerGatewayVerbGroups and hands back only BrowserGatewayService, which exposes no page handle, '
|
|
84
|
+
+ 'and builds it with no cardFieldGuard, so its secret-fill path refuses by design.',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'payments.checkout.fillCard',
|
|
88
|
+
reason: 'Same missing page driver; this is the verb that types the card into it.',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const DEFAULT_PURCHASE_LIST_LIMIT = 100;
|
|
93
|
+
|
|
94
|
+
export interface PaymentsHandlerDeps {
|
|
95
|
+
readonly cards: DaemonCardStore;
|
|
96
|
+
readonly purchases: DaemonPurchaseLedger;
|
|
97
|
+
/**
|
|
98
|
+
* Today's pools. Read-only in this composition: the only writer of a spend
|
|
99
|
+
* record is the checkout flow, which is not attached, so this ledger reports
|
|
100
|
+
* limits from live config against an empty spend history. Wiring checkout must
|
|
101
|
+
* also make this ledger DURABLE, a ledger rebuilt at every boot would hand
|
|
102
|
+
* back a daily budget that was already spent.
|
|
103
|
+
*/
|
|
104
|
+
readonly budget: BudgetLedger;
|
|
105
|
+
readonly config: PaymentsConfigReader;
|
|
106
|
+
/**
|
|
107
|
+
* Whether this node is the one allowed to spend.
|
|
108
|
+
*
|
|
109
|
+
* Reported, never defaulted, see the SDK's gates.ts: on a clustered install a
|
|
110
|
+
* wrong answer here is a double-spend. The composition root supplies the
|
|
111
|
+
* coordinator's own answer.
|
|
112
|
+
*/
|
|
113
|
+
readonly isPaymentsLeader: () => boolean;
|
|
114
|
+
readonly now?: (() => number) | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Input readers
|
|
119
|
+
//
|
|
120
|
+
// Each names the FIELD and never the value, the property the SDK's own route
|
|
121
|
+
// module enforces: an error string is a read path like any other.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function invalid(field: string, requirement: string): HandlerError {
|
|
125
|
+
return new HandlerError(`${field} ${requirement}`, 'INVALID_ARGUMENT', 400);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a card-store call and refuse in the caller's terms.
|
|
130
|
+
*
|
|
131
|
+
* Two outcomes, and the difference is what the caller is allowed to be told:
|
|
132
|
+
*
|
|
133
|
+
* - `CardStoreUnreadableError` is a message this codebase WROTE, naming the
|
|
134
|
+
* file and what to do about it. It is forwarded verbatim because the operator
|
|
135
|
+
* cannot fix a damaged card file they are not told about, and 409 says the
|
|
136
|
+
* honest thing: nothing is wrong with the request, the store is not in a
|
|
137
|
+
* state that can serve it.
|
|
138
|
+
* - Anything else came out of the secret store, and its message can name the
|
|
139
|
+
* store path, the key, or the value it was handling. It is DISCARDED and
|
|
140
|
+
* replaced here. Without this, `registerCatalogHandler`'s generic wrapper
|
|
141
|
+
* forwards the original as a 500 body, which put a store path in front of any
|
|
142
|
+
* caller holding read:payments.
|
|
143
|
+
*/
|
|
144
|
+
async function overStore<T>(what: string, run: () => Promise<T>): Promise<T> {
|
|
145
|
+
try {
|
|
146
|
+
return await run();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof CardStoreUnreadableError) {
|
|
149
|
+
throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
|
|
150
|
+
}
|
|
151
|
+
void error;
|
|
152
|
+
throw new HandlerError(`${what} failed.`, 'INTERNAL_ERROR', 500);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readString(source: Record<string, unknown>, field: string): string {
|
|
157
|
+
const value = source[field];
|
|
158
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
159
|
+
throw invalid(field, 'is required.');
|
|
160
|
+
}
|
|
161
|
+
return value.trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readInteger(source: Record<string, unknown>, field: string, min: number, max: number): number {
|
|
165
|
+
const value = source[field];
|
|
166
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
|
167
|
+
throw invalid(field, `must be a whole number between ${String(min)} and ${String(max)}.`);
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
173
|
+
return typeof body === 'object' && body !== null && !Array.isArray(body)
|
|
174
|
+
? (body as Record<string, unknown>)
|
|
175
|
+
: {};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A number that arrived as a query string (a GET) or as JSON (an invoke). */
|
|
179
|
+
function optionalCount(raw: unknown): number | undefined {
|
|
180
|
+
if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0) return raw;
|
|
181
|
+
if (typeof raw === 'string' && /^[0-9]+$/.test(raw.trim())) {
|
|
182
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
183
|
+
if (parsed > 0) return parsed;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Response builders (allowlists, never spreads)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
interface CardView {
|
|
193
|
+
readonly id: string;
|
|
194
|
+
readonly label: string;
|
|
195
|
+
readonly brand: string;
|
|
196
|
+
readonly last4: string;
|
|
197
|
+
readonly kind: 'virtual' | 'real';
|
|
198
|
+
readonly expiryMonth: number;
|
|
199
|
+
readonly expiryYear: number;
|
|
200
|
+
readonly issuerCapMinorUnits: number | null;
|
|
201
|
+
readonly addedAt: string;
|
|
202
|
+
readonly materialComplete: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cardView(card: CardMetadata, materialComplete: boolean): CardView {
|
|
206
|
+
return {
|
|
207
|
+
id: card.id,
|
|
208
|
+
label: card.label,
|
|
209
|
+
brand: card.brand,
|
|
210
|
+
last4: card.last4,
|
|
211
|
+
kind: card.kind,
|
|
212
|
+
expiryMonth: card.expiryMonth,
|
|
213
|
+
expiryYear: card.expiryYear,
|
|
214
|
+
issuerCapMinorUnits: card.issuerCapMinorUnits,
|
|
215
|
+
addedAt: card.addedAt,
|
|
216
|
+
materialComplete,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function purchaseView(row: StoredPurchase): Record<string, unknown> {
|
|
221
|
+
return {
|
|
222
|
+
purchaseId: row.purchaseId,
|
|
223
|
+
atUtc: row.atUtc,
|
|
224
|
+
dayKey: row.dayKey,
|
|
225
|
+
timezone: row.timezone,
|
|
226
|
+
merchantDomain: row.merchantDomain,
|
|
227
|
+
item: String(row.item),
|
|
228
|
+
currency: String(row.currency),
|
|
229
|
+
itemMinorUnits: row.itemMinorUnits,
|
|
230
|
+
taxMinorUnits: row.taxMinorUnits,
|
|
231
|
+
feesMinorUnits: row.feesMinorUnits,
|
|
232
|
+
shippingMinorUnits: row.shippingMinorUnits,
|
|
233
|
+
totalMinorUnits: row.totalMinorUnits,
|
|
234
|
+
shippingTierRequested: row.shippingTierRequested,
|
|
235
|
+
shippingTierUsed: row.shippingTierUsed,
|
|
236
|
+
steppedDown: row.steppedDown === true,
|
|
237
|
+
itemPoolDraw: row.itemPoolDraw,
|
|
238
|
+
overagePoolDraw: row.overagePoolDraw,
|
|
239
|
+
tolerancePoolDraw: row.tolerancePoolDraw,
|
|
240
|
+
cardLast4: row.cardLast4,
|
|
241
|
+
windowKind: row.windowKind,
|
|
242
|
+
windowOutcome: row.windowOutcome,
|
|
243
|
+
answeredBy: row.answeredBy ?? null,
|
|
244
|
+
outcome: row.outcome,
|
|
245
|
+
refusalReason: row.refusalReason ?? null,
|
|
246
|
+
merchantOrderId: row.merchantOrderId ?? null,
|
|
247
|
+
refundedAt: row.refundedAt ?? null,
|
|
248
|
+
merchantRecognised: row.merchantRecognised === true,
|
|
249
|
+
merchantQualifier: row.merchantQualifier ?? null,
|
|
250
|
+
merchantDiscovered: row.merchantDiscovered === true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Registration
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Attach the five answerable `payments.*` handlers to the descriptors the SDK
|
|
260
|
+
* catalog already holds. Returns the teardown, reverse order, like every other
|
|
261
|
+
* surface in this layer.
|
|
262
|
+
*
|
|
263
|
+
* NOT gated on `payments.enabled`. That key defaults to false, and
|
|
264
|
+
* `payments.cards.*` is how a surface CONFIGURES the capability, so gating
|
|
265
|
+
* registration on it would leave the configuration surface unreachable until the
|
|
266
|
+
* capability was already configured, which is the shape of the defect this
|
|
267
|
+
* module exists to fix. The setting is reported live by `budget.status` instead,
|
|
268
|
+
* and it is `checkPaymentGates` at purchase time that stops a disabled daemon
|
|
269
|
+
* from spending.
|
|
270
|
+
*/
|
|
271
|
+
export function registerPaymentsMethods(
|
|
272
|
+
catalog: GatewayMethodCatalog,
|
|
273
|
+
deps: PaymentsHandlerDeps,
|
|
274
|
+
): Unregister {
|
|
275
|
+
const now = deps.now ?? Date.now;
|
|
276
|
+
|
|
277
|
+
const budgetStatus: TypedHandler<unknown, Record<string, unknown>> = async () => {
|
|
278
|
+
const config = readPaymentsServiceConfig(deps.config);
|
|
279
|
+
const nowMs = now();
|
|
280
|
+
const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
|
|
281
|
+
const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
|
|
282
|
+
return {
|
|
283
|
+
enabled: readPaymentsEnabled(deps.config),
|
|
284
|
+
dayKey: String(pools.dayKey),
|
|
285
|
+
timezone: pools.timezone,
|
|
286
|
+
currency: String(config.budgetCurrency),
|
|
287
|
+
item: { ...pools.item },
|
|
288
|
+
overage: { ...pools.overage },
|
|
289
|
+
tolerance: { ...pools.tolerance },
|
|
290
|
+
reservationCount: live.length,
|
|
291
|
+
isPaymentsLeader: deps.isPaymentsLeader(),
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const cardsList: TypedHandler<unknown, Record<string, unknown>> = async () => {
|
|
296
|
+
const views = await overStore('Listing the stored cards', async () => {
|
|
297
|
+
const built: CardView[] = [];
|
|
298
|
+
for (const card of deps.cards.list()) {
|
|
299
|
+
built.push(cardView(card, await deps.cards.materialComplete(card.id)));
|
|
300
|
+
}
|
|
301
|
+
return built;
|
|
302
|
+
});
|
|
303
|
+
return { cards: views, defaultCardId: readDefaultCardId(deps.config) };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
|
|
307
|
+
const params = asRecord(body);
|
|
308
|
+
const kind = readString(params, 'kind');
|
|
309
|
+
if (kind !== 'virtual' && kind !== 'real') {
|
|
310
|
+
throw invalid('kind', "must be 'virtual' or 'real'.");
|
|
311
|
+
}
|
|
312
|
+
const label = readString(params, 'label');
|
|
313
|
+
const number = readString(params, 'number');
|
|
314
|
+
// Narrower than the published input schema, which types these as a plain
|
|
315
|
+
// string and a plain number. Each check below is a property of BEING a card
|
|
316
|
+
// rather than a policy about one: a value that fails it could not be
|
|
317
|
+
// charged, and storing it would produce a card the surface offers and the
|
|
318
|
+
// checkout can never fill. None of them names anything but the field.
|
|
319
|
+
if (number.replace(/\D/g, '').length < 12) {
|
|
320
|
+
throw invalid('number', 'does not contain enough digits to be a card number.');
|
|
321
|
+
}
|
|
322
|
+
const expiryMonth = readInteger(params, 'expiryMonth', 1, 12);
|
|
323
|
+
// A full four-digit year: `cardFieldValue` derives the two-digit form by
|
|
324
|
+
// slicing this one, so a year stored as 29 would type as "29" in a
|
|
325
|
+
// four-digit field and as "29" in a two-digit one, and only one of those is
|
|
326
|
+
// right.
|
|
327
|
+
const expiryYear = readInteger(params, 'expiryYear', 1000, 9999);
|
|
328
|
+
const cvv = readString(params, 'cvv');
|
|
329
|
+
if (!/^[0-9]{3,4}$/.test(cvv)) {
|
|
330
|
+
throw invalid('cvv', 'must be the three or four digit code printed on the card.');
|
|
331
|
+
}
|
|
332
|
+
const cardholderName = readString(params, 'cardholderName');
|
|
333
|
+
const rawCap = params['issuerCapMinorUnits'];
|
|
334
|
+
|
|
335
|
+
let card: CardMetadata;
|
|
336
|
+
try {
|
|
337
|
+
card = await deps.cards.create({
|
|
338
|
+
label,
|
|
339
|
+
kind,
|
|
340
|
+
number,
|
|
341
|
+
expiryMonth,
|
|
342
|
+
expiryYear,
|
|
343
|
+
cvv,
|
|
344
|
+
cardholderName,
|
|
345
|
+
issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// A damaged card file is the operator's to fix and its message says how,
|
|
349
|
+
// so it is forwarded; see overStore. Everything else is discarded, because
|
|
350
|
+
// the failing call had the card in its arguments.
|
|
351
|
+
if (error instanceof CardStoreUnreadableError) {
|
|
352
|
+
throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
|
|
353
|
+
}
|
|
354
|
+
void error;
|
|
355
|
+
throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
card: cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id))),
|
|
359
|
+
};
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const cardsDelete: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
|
|
363
|
+
// The REST path is `/api/payments/cards/{id}`, whose path parameter the
|
|
364
|
+
// dispatcher folds into BOTH query and body; the methodId-invoke endpoint
|
|
365
|
+
// carries it in the body only. Reading both is what makes the two paths the
|
|
366
|
+
// same verb rather than two.
|
|
367
|
+
const id = readString({ ...query, ...asRecord(body) }, 'id');
|
|
368
|
+
const result = await overStore('Deleting the card', () => deps.cards.remove(id));
|
|
369
|
+
return { id, deleted: result.deleted, secretsCleared: result.secretsCleared };
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const purchasesList: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
|
|
373
|
+
const params = { ...query, ...asRecord(body) };
|
|
374
|
+
const requested = optionalCount(params['limit']);
|
|
375
|
+
const rawDay = params['dayKey'];
|
|
376
|
+
const dayKey = typeof rawDay === 'string' && rawDay.trim().length > 0 ? rawDay.trim() : undefined;
|
|
377
|
+
const result = deps.purchases.list({
|
|
378
|
+
limit: Math.min(requested ?? DEFAULT_PURCHASE_LIST_LIMIT, MAX_PURCHASE_LIST_LIMIT),
|
|
379
|
+
dayKey,
|
|
380
|
+
});
|
|
381
|
+
return { purchases: result.purchases.map(purchaseView), total: result.total };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return registerCatalogHandlers(catalog, [
|
|
385
|
+
{ id: 'payments.budget.status', handler: budgetStatus as TypedHandler<unknown, unknown> },
|
|
386
|
+
{ id: 'payments.cards.list', handler: cardsList as TypedHandler<unknown, unknown> },
|
|
387
|
+
{ id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
|
|
388
|
+
{ id: 'payments.cards.delete', handler: cardsDelete as TypedHandler<unknown, unknown> },
|
|
389
|
+
{ id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
@@ -60,8 +60,8 @@ function errorMessage(error: unknown): string {
|
|
|
60
60
|
*
|
|
61
61
|
* An ABSENT context is accepted and read as the empty one: no principal, no
|
|
62
62
|
* scopes, not admin, nobody claiming a person asked. The type says a context is
|
|
63
|
-
* always there and at runtime it is not always
|
|
64
|
-
* builds the invocation by hand can omit it
|
|
63
|
+
* always there and at runtime it is not always, an in-process invoke that
|
|
64
|
+
* builds the invocation by hand can omit it, and reading `.metadata` off
|
|
65
65
|
* `undefined` turned that into a TypeError thrown out of the handler wrapper
|
|
66
66
|
* instead of the refusal every caller can act on. Defaulting to the least
|
|
67
67
|
* privilege is the only safe reading: it can cost a caller an authorization it
|
|
@@ -124,7 +124,7 @@ export function registerCatalogHandler<TBody, TResult>(
|
|
|
124
124
|
const descriptor = catalog.get(methodId);
|
|
125
125
|
if (!descriptor) {
|
|
126
126
|
// 'METHOD_NOT_FOUND' (not the old locally-coined 'UNKNOWN_METHOD') so this lines
|
|
127
|
-
// up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND
|
|
127
|
+
// up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND, the code the SDK's own
|
|
128
128
|
// uncataloged-method 404 now carries (method-catalog.ts's GatewayMethodCatalog
|
|
129
129
|
// .invoke(), daemon/control-plane.ts's invokeGatewayMethodCall, and daemon-sdk's
|
|
130
130
|
// control-routes.ts getGatewayMethod/invokeGatewayMethod). A literal string, not
|
|
@@ -20,12 +20,20 @@ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
|
|
|
20
20
|
* Cloud-terminal backend: executes a command in a managed cloud shell / VM via
|
|
21
21
|
* the provider CLI (gcloud / aws / az). The provider credential is resolved from
|
|
22
22
|
* the daemon credential store and supplied to the CLI via a 0600 credentials
|
|
23
|
-
* file or a provider-specific env var
|
|
23
|
+
* file or a provider-specific env var, never as an argv token, never logged.
|
|
24
24
|
*/
|
|
25
25
|
export function createCloudTerminalBackend(ctx: BackendContext): Backend {
|
|
26
26
|
const credDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'cloud-creds');
|
|
27
|
+
// Crash-window sweep: dispatch's finally block only removes a credential
|
|
28
|
+
// file on an orderly return; a hard crash mid-dispatch (SIGKILL, OOM-kill,
|
|
29
|
+
// power loss) skips both that and teardown(), leaving the file behind for
|
|
30
|
+
// every daemon restart until now. Sweep at construction, before any
|
|
31
|
+
// dispatch on this instance can have written anything, so a crash converges
|
|
32
|
+
// to the same clean state a graceful shutdown already produces.
|
|
33
|
+
const credDirSwept = rm(credDir, { recursive: true, force: true }).catch(() => {});
|
|
27
34
|
|
|
28
35
|
async function writeCredentialFile(peerId: string, value: string): Promise<string> {
|
|
36
|
+
await credDirSwept;
|
|
29
37
|
await mkdir(credDir, { recursive: true });
|
|
30
38
|
await chmod(credDir, 0o700).catch(() => {});
|
|
31
39
|
const suffix = randomBytes(4).toString('hex');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared subprocess runner built on Bun.spawn. Captures stdout/stderr/exit code
|
|
2
2
|
// with a hard timeout. Used by every backend that shells out (docker/ssh/cloud/
|
|
3
|
-
// local-process). No credentials are ever passed as argv
|
|
3
|
+
// local-process). No credentials are ever passed as argv, callers pass key
|
|
4
4
|
// material via files or the `env` overlay.
|
|
5
5
|
|
|
6
6
|
export interface RunOptions {
|
|
@@ -20,7 +20,7 @@ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
|
|
|
20
20
|
* Persistent-key material is written to {homeDirectory}/.goodvibes/tui/operator/
|
|
21
21
|
* ssh-keys/{peerId}.key with 0600 permissions and reused across invocations
|
|
22
22
|
* (connection pooling via the OpenSSH ControlMaster multiplexer). The key value
|
|
23
|
-
* itself comes only from the daemon credential store
|
|
23
|
+
* itself comes only from the daemon credential store, never argv, never logs.
|
|
24
24
|
*/
|
|
25
25
|
interface PooledIdentity {
|
|
26
26
|
keyPath: string;
|
|
@@ -31,6 +31,13 @@ interface PooledIdentity {
|
|
|
31
31
|
export function createSshBackend(ctx: BackendContext): Backend {
|
|
32
32
|
const pool = new Map<string, PooledIdentity>();
|
|
33
33
|
const keyDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'ssh-keys');
|
|
34
|
+
// Crash-window sweep: an abrupt daemon exit (SIGKILL, OOM-kill, power loss)
|
|
35
|
+
// skips teardown() and can leave a previous process's key files behind. The
|
|
36
|
+
// pool above starts empty regardless, so those leftovers are unreachable
|
|
37
|
+
// dead weight; clear them here so a crash converges to the same clean state
|
|
38
|
+
// a graceful shutdown already produces. Every write below waits on this
|
|
39
|
+
// first so the sweep can never race a key this instance just wrote.
|
|
40
|
+
const keyDirSwept = rm(keyDir, { recursive: true, force: true }).catch(() => {});
|
|
34
41
|
|
|
35
42
|
async function ensureIdentity(
|
|
36
43
|
peer: PeerRecord,
|
|
@@ -40,6 +47,7 @@ export function createSshBackend(ctx: BackendContext): Backend {
|
|
|
40
47
|
if (existing && existing.identityRef === config.identityRef) {
|
|
41
48
|
return existing;
|
|
42
49
|
}
|
|
50
|
+
await keyDirSwept;
|
|
43
51
|
const key = await ctx.credentials.resolveRef(config.identityRef);
|
|
44
52
|
if (!key || key.length === 0) {
|
|
45
53
|
throw new BackendDispatchError(
|
|
@@ -29,7 +29,7 @@ export interface BackendDispatchResult {
|
|
|
29
29
|
export interface BackendContext {
|
|
30
30
|
credentials: DaemonCredentialStore;
|
|
31
31
|
logger: HandlerLogger;
|
|
32
|
-
/** Daemon home dir
|
|
32
|
+
/** Daemon home dir, used for ephemeral key material under a 0700 subdir. */
|
|
33
33
|
homeDirectory: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -84,7 +84,7 @@ export class BackendDispatchError extends Error {
|
|
|
84
84
|
* REMOTE-SHELL SEMANTICS (intentional, documented asymmetry vs local-process):
|
|
85
85
|
* positional `payload.args` are joined onto the command with a single space and
|
|
86
86
|
* are NOT shell-escaped, because these backends hand a single command STRING to
|
|
87
|
-
* a remote shell
|
|
87
|
+
* a remote shell, the operator's `command` may itself contain pipes, redirects,
|
|
88
88
|
* globs, or quoting that must survive the hop verbatim. The local-process
|
|
89
89
|
* backend, by contrast, never invokes a shell and passes args as discrete argv.
|
|
90
90
|
*
|
|
@@ -17,7 +17,7 @@ function sha256First(input: string, hexChars: number): string {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
|
-
// Work-item hook
|
|
20
|
+
// Work-item hook, long-running invocations are enqueued as work items visible
|
|
21
21
|
// in remote.work.list. The dispatcher does not own the distributed runtime; the
|
|
22
22
|
// integrator wires this hook to the DistributedRuntimeManager work queue.
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -36,7 +36,7 @@ export interface RemoteWorkEnqueuer {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
|
-
// Invoke result
|
|
39
|
+
// Invoke result, returned to the agent through remote.peers.invoke. Includes
|
|
40
40
|
// stdoutDigest (sha256 of FULL stdout, 64 hex chars) per the receipt contract.
|
|
41
41
|
// The agent may receive only a truncated stdout preview.
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
@@ -168,7 +168,7 @@ export class RemoteDispatcher {
|
|
|
168
168
|
/**
|
|
169
169
|
* Best-effort teardown: invoke every backend's optional teardown so ephemeral
|
|
170
170
|
* key/credential material (ssh-keys/, cloud-creds/) is swept from disk and
|
|
171
|
-
* does not outlive the daemon. Failures are swallowed
|
|
171
|
+
* does not outlive the daemon. Failures are swallowed, teardown must never
|
|
172
172
|
* throw during surface shutdown.
|
|
173
173
|
*/
|
|
174
174
|
async teardown(): Promise<void> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote handler surface
|
|
2
|
+
* Remote handler surface, the host backend for `remote.peers.*`.
|
|
3
3
|
*
|
|
4
4
|
* `remote.peers.invoke` is NOT a catalog method: the SDK publishes it as an HTTP
|
|
5
5
|
* route and injects a `DistributedRuntimeRouteService` (the host's
|
|
@@ -27,7 +27,7 @@ export interface SshBackendConfig {
|
|
|
27
27
|
sshHost: string;
|
|
28
28
|
sshPort?: number;
|
|
29
29
|
sshUser: string;
|
|
30
|
-
/** goodvibes://secrets/ reference to the private key
|
|
30
|
+
/** goodvibes://secrets/ reference to the private key, never the raw key. */
|
|
31
31
|
identityRef: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -71,7 +71,7 @@ export interface PeerRegistrationInput {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
// Validation helpers
|
|
74
|
+
// Validation helpers, backendConfig must hold ONLY secret refs for any
|
|
75
75
|
// credential-bearing field. Raw secrets are rejected outright.
|
|
76
76
|
// ---------------------------------------------------------------------------
|
|
77
77
|
|
|
@@ -111,9 +111,9 @@ function requireSecretRef(value: unknown, field: string): string {
|
|
|
111
111
|
function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
112
112
|
// A dockerHost is accepted in exactly two shapes, mirroring how docker.ts
|
|
113
113
|
// resolves it (docker.ts: `startsWith('goodvibes://') ? resolveRef(...) : raw`):
|
|
114
|
-
// 1. A goodvibes://secrets/ reference
|
|
114
|
+
// 1. A goodvibes://secrets/ reference, resolved from the credential store.
|
|
115
115
|
// 2. A credential-free local/plain address (unix:// socket, or a bare
|
|
116
|
-
// tcp/host with no embedded userinfo)
|
|
116
|
+
// tcp/host with no embedded userinfo), used verbatim.
|
|
117
117
|
// Enforcement here must match that resolution so no credential-bearing or
|
|
118
118
|
// unresolvable value slips through to docker.ts.
|
|
119
119
|
if (value === undefined) return;
|
|
@@ -123,7 +123,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
|
123
123
|
|
|
124
124
|
// A `goodvibes://` value that is NOT a well-formed secret ref would be handed
|
|
125
125
|
// to credentials.resolveRef() and fail opaquely (REMOTE_BACKEND_CREDENTIAL_MISSING)
|
|
126
|
-
|
|
126
|
+
//, or, worse, a near-miss could be treated as a literal host. Reject it at
|
|
127
127
|
// registration so the misconfiguration surfaces immediately.
|
|
128
128
|
if (isMalformedGoodVibesSecretReferenceValue(value)) {
|
|
129
129
|
throw new PeerRegistryValidationError(
|
|
@@ -132,7 +132,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Embedded userinfo credentials (e.g. tcp://user:pass@host) must never be
|
|
135
|
-
// stored raw
|
|
135
|
+
// stored raw, docker.ts would pass them verbatim as DOCKER_HOST.
|
|
136
136
|
if (value.includes('@')) {
|
|
137
137
|
throw new PeerRegistryValidationError(
|
|
138
138
|
`Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
|
|
@@ -226,7 +226,7 @@ export function normalizeBackendConfig(
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
// ---------------------------------------------------------------------------
|
|
229
|
-
// Peer registry
|
|
229
|
+
// Peer registry, persisted via HandlerSqliteStore (peer-registry.sqlite)
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
|
|
232
232
|
const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
|
|
@@ -247,21 +247,62 @@ interface PeerRow {
|
|
|
247
247
|
backendConfig: string;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
// Typed ReadonlySet<string> (not ReadonlySet<BackendKind>) so isBackendKind
|
|
251
|
+
// below can call .has() with a plain string and let the function's own `value
|
|
252
|
+
// is BackendKind` signature do the narrowing, instead of casting the set.
|
|
253
|
+
const VALID_BACKEND_KINDS: ReadonlySet<string> = new Set<BackendKind>([
|
|
251
254
|
'docker',
|
|
252
255
|
'ssh',
|
|
253
256
|
'cloud-terminal',
|
|
254
257
|
'local-process',
|
|
255
258
|
]);
|
|
256
259
|
|
|
260
|
+
/** True when `value` is one of the four known backend kinds; narrows to BackendKind. */
|
|
261
|
+
function isBackendKind(value: string): value is BackendKind {
|
|
262
|
+
return VALID_BACKEND_KINDS.has(value);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
266
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Turn a stored row back into a typed PeerRecord. backendConfig is written
|
|
271
|
+
* only by register() through normalizeBackendConfig(), but a row read back
|
|
272
|
+
* from disk is untrusted the same way a fresh registration's raw input is: a
|
|
273
|
+
* hand-edited database file, a row left over from a schema this file no
|
|
274
|
+
* longer writes, or on-disk corruption can all put something here that was
|
|
275
|
+
* never actually normalized. Re-running it through normalizeBackendConfig
|
|
276
|
+
* catches that at the read boundary instead of handing a malformed object to
|
|
277
|
+
* a caller that assumes register()'s guarantees already hold.
|
|
278
|
+
*/
|
|
257
279
|
function rowToRecord(row: PeerRow): PeerRecord {
|
|
258
|
-
|
|
259
|
-
|
|
280
|
+
if (!isBackendKind(row.backendKind)) {
|
|
281
|
+
throw new PeerRegistryValidationError(
|
|
282
|
+
`Peer '${row.peerId}' has an unknown backendKind '${row.backendKind}'; the row is corrupt or from an unsupported version.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const backendKind = row.backendKind;
|
|
286
|
+
|
|
287
|
+
let raw: unknown;
|
|
288
|
+
try {
|
|
289
|
+
raw = JSON.parse(row.backendConfig);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new PeerRegistryValidationError(
|
|
292
|
+
`Peer '${row.peerId}' has a backendConfig that is not valid JSON; the row is corrupt.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!isPlainObject(raw)) {
|
|
296
|
+
throw new PeerRegistryValidationError(
|
|
297
|
+
`Peer '${row.peerId}' has a backendConfig that is not an object; the row is corrupt.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
260
301
|
return {
|
|
261
302
|
peerId: row.peerId,
|
|
262
303
|
displayName: row.displayName,
|
|
263
304
|
backendKind,
|
|
264
|
-
backendConfig:
|
|
305
|
+
backendConfig: normalizeBackendConfig(backendKind, raw),
|
|
265
306
|
};
|
|
266
307
|
}
|
|
267
308
|
|
|
@@ -335,10 +376,18 @@ export class PeerRegistry {
|
|
|
335
376
|
return rows.map(rowToRecord);
|
|
336
377
|
}
|
|
337
378
|
|
|
338
|
-
/**
|
|
379
|
+
/**
|
|
380
|
+
* Remove a peer. Returns true when a row was deleted. Existence is checked
|
|
381
|
+
* without row validation so a corrupt row (which get() rejects) can still
|
|
382
|
+
* be removed.
|
|
383
|
+
*/
|
|
339
384
|
async remove(peerId: string): Promise<boolean> {
|
|
340
385
|
this.requireInit();
|
|
341
|
-
const
|
|
386
|
+
const row = this.store.all<{ peerId: string }>(
|
|
387
|
+
'SELECT peerId FROM peers WHERE peerId = ?',
|
|
388
|
+
[peerId],
|
|
389
|
+
);
|
|
390
|
+
const existed = row.length > 0;
|
|
342
391
|
if (existed) {
|
|
343
392
|
this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
|
|
344
393
|
await this.store.save();
|