@pellux/goodvibes-daemon 1.28.19 → 1.28.21
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 +119 -63
- package/README.md +24 -19
- 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 +19 -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/address-store.ts +54 -0
- package/src/daemon/handlers/payments/budget-store.ts +356 -0
- package/src/daemon/handlers/payments/card-store.ts +486 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
- package/src/daemon/handlers/payments/index.ts +38 -0
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
- package/src/daemon/handlers/payments/register.ts +518 -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/browser-checkout-seam-holder.ts +55 -0
- 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 +61 -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 +187 -0
- package/src/runtime/plugin-composition.ts +7 -7
- package/src/runtime/runtime-services-types.ts +9 -9
- package/src/runtime/services.ts +41 -32
- 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,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* card-store.ts, the daemon's card file and the material behind it.
|
|
3
|
+
*
|
|
4
|
+
* The SDK states the arrangement and implements neither half of it:
|
|
5
|
+
* `CardMaterialStore` (platform/payments/card-material.ts) says "it is
|
|
6
|
+
* implemented by the daemon against its own secret store", and the TUI's
|
|
7
|
+
* payments-config.ts says card material is "named by keys the daemon's own
|
|
8
|
+
* `payments.cards.create` control-plane method derives internally". This module
|
|
9
|
+
* is that implementation, and the split it enforces is the whole point:
|
|
10
|
+
*
|
|
11
|
+
* - METADATA (id, label, brand, last4, kind, expiry, declared issuer cap,
|
|
12
|
+
* addedAt) lives in a plain JSON file. Nothing in it can identify a card to
|
|
13
|
+
* a merchant, so it is readable by any verb that lists cards.
|
|
14
|
+
* - MATERIAL (number, expiry parts, CVV, cardholder name) lives one field per
|
|
15
|
+
* key in the daemon secret tier and is reachable only through `read()`,
|
|
16
|
+
* which is in-process by construction: nothing that calls it is a handler.
|
|
17
|
+
*
|
|
18
|
+
* `read()` is all-or-nothing. A partially-present card resolves to null rather
|
|
19
|
+
* than to an object with empty strings in it, because the caller of `read()`
|
|
20
|
+
* types what it is given into a checkout form, and a form filled with four
|
|
21
|
+
* fields and a blank is a submitted order with a wrong card on it.
|
|
22
|
+
*
|
|
23
|
+
* ── The CVV and `payments.cvvHandling` ────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* `'prompt'` is the owner saying the CVV must not be kept. This module obeys it
|
|
26
|
+
* on BOTH sides: it is not written at create time, and it is not supplied at
|
|
27
|
+
* read time whatever is already in the store, so flipping the setting takes
|
|
28
|
+
* effect on the cards that already exist instead of only on the next one. Under
|
|
29
|
+
* 'prompt' a card reports `materialComplete: false` and `read()` answers null,
|
|
30
|
+
* which is exactly what that flag is for, the descriptor for
|
|
31
|
+
* `payments.cards.list` names 'CVV not set' as the case a surface renders from
|
|
32
|
+
* it. The stored bytes are not purged; see `suppliesStoredCvv`.
|
|
33
|
+
*
|
|
34
|
+
* ── A damaged card file is refused, never read as empty ───────────────────
|
|
35
|
+
*
|
|
36
|
+
* `readCardsFile` reports three outcomes, not two, mirroring the SDK's own
|
|
37
|
+
* secret-store reader for the reason it gives: collapsing "cannot read" into
|
|
38
|
+
* "empty" is how a damaged file becomes a destroyed one. Here it was worse than
|
|
39
|
+
* data loss. A `create` against a corrupt file rewrote the file with only the
|
|
40
|
+
* new card, and the material of every card it silently dropped stayed in the
|
|
41
|
+
* secret tier with no row left to reach it by, so `payments.cards.delete` could
|
|
42
|
+
* not clear it either. Every path through this module now refuses while the
|
|
43
|
+
* file exists and does not parse.
|
|
44
|
+
*
|
|
45
|
+
* ── No value ever reaches a log line or an error ──────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* Every failure below is either raised with a message written here, or left to
|
|
48
|
+
* the caller. Nothing catches a secret-store error and forwards its message: the
|
|
49
|
+
* failing call had the card in its arguments.
|
|
50
|
+
*/
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
52
|
+
import { randomBytes } from 'node:crypto';
|
|
53
|
+
import { dirname } from 'node:path';
|
|
54
|
+
import { atomicWriteFileSync } from '@pellux/goodvibes-sdk/platform/config';
|
|
55
|
+
import type { CardMaterial, CardMaterialStore, CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
|
|
56
|
+
import { normalizeSecretKeyPart } from '../../../config/secret-config.ts';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// The secret port
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The three operations this store performs against the daemon secret tier.
|
|
64
|
+
*
|
|
65
|
+
* Narrower than `SecretsManager` on purpose, the same treatment
|
|
66
|
+
* cluster-group-composition.ts gives the group key: a store that held the whole
|
|
67
|
+
* manager could read any credential in the process, and this one has business
|
|
68
|
+
* with five keys per card and nothing else. The composition root binds the
|
|
69
|
+
* scope; nothing here chooses where a secret lands.
|
|
70
|
+
*/
|
|
71
|
+
export interface PaymentsSecretStore {
|
|
72
|
+
get(key: string): Promise<string | null>;
|
|
73
|
+
set(key: string, value: string): Promise<void>;
|
|
74
|
+
delete(key: string): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How the owner wants the CVV handled, read live (`payments.cvvHandling`). */
|
|
78
|
+
export type CvvHandling = 'stored' | 'prompt';
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Secret key derivation
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The five material fields, and the suffix each one's secret key carries.
|
|
86
|
+
*
|
|
87
|
+
* Spelled as a list rather than derived from the `CardMaterial` type so that
|
|
88
|
+
* adding a field to that interface does not silently start writing a sixth
|
|
89
|
+
* secret nobody decided to store.
|
|
90
|
+
*/
|
|
91
|
+
export const CARD_MATERIAL_FIELDS = [
|
|
92
|
+
'number',
|
|
93
|
+
'expiryMonth',
|
|
94
|
+
'expiryYear',
|
|
95
|
+
'cvv',
|
|
96
|
+
'cardholderName',
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
export type CardMaterialField = (typeof CARD_MATERIAL_FIELDS)[number];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `GOODVIBES_PAYMENTS_CARD_<ID>_<FIELD>`.
|
|
103
|
+
*
|
|
104
|
+
* Built through the same `normalizeSecretKeyPart` every other daemon secret key
|
|
105
|
+
* goes through (config/secret-config.ts), so a card key looks like the mailbox
|
|
106
|
+
* password key beside it in the store rather than like a second convention.
|
|
107
|
+
*/
|
|
108
|
+
export function cardSecretKey(cardId: string, field: CardMaterialField): string {
|
|
109
|
+
return `GOODVIBES_PAYMENTS_CARD_${normalizeSecretKeyPart(cardId)}_${normalizeSecretKeyPart(field)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Metadata derived from the number, at the moment it is stored
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The brand, from the issuer identification number.
|
|
118
|
+
*
|
|
119
|
+
* Derived once, at create time, from a number this process is holding anyway,
|
|
120
|
+
* and then the number is gone. An unrecognised prefix reports 'unknown' rather
|
|
121
|
+
* than guessing: the field is a label on a settings screen, and a wrong label is
|
|
122
|
+
* worse than an honest blank one.
|
|
123
|
+
*/
|
|
124
|
+
export function cardBrand(digits: string): string {
|
|
125
|
+
if (/^4/.test(digits)) return 'visa';
|
|
126
|
+
if (/^(5[1-5]|2(2[2-9]|[3-6]\d|7[01]|720))/.test(digits)) return 'mastercard';
|
|
127
|
+
if (/^3[47]/.test(digits)) return 'amex';
|
|
128
|
+
if (/^(6011|65|64[4-9]|622)/.test(digits)) return 'discover';
|
|
129
|
+
if (/^3(0[0-5]|[689])/.test(digits)) return 'diners';
|
|
130
|
+
if (/^35(2[89]|[3-8]\d)/.test(digits)) return 'jcb';
|
|
131
|
+
return 'unknown';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A fresh card id. Random, not derived from the number: a derived id is a digest of it. */
|
|
135
|
+
export function newCardId(): string {
|
|
136
|
+
return `card-${randomBytes(6).toString('hex')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// The metadata file
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
const CARDS_FILE_VERSION = 1;
|
|
144
|
+
|
|
145
|
+
interface CardsFile {
|
|
146
|
+
readonly version: number;
|
|
147
|
+
readonly cards: readonly CardMetadata[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Raised when the card file exists and cannot be read.
|
|
152
|
+
*
|
|
153
|
+
* A distinct type, not a bare Error, because the handlers have to tell it apart
|
|
154
|
+
* from every other failure: this one carries an operator instruction and is
|
|
155
|
+
* meant to reach the caller verbatim, and the others carry a store path that
|
|
156
|
+
* must not.
|
|
157
|
+
*/
|
|
158
|
+
export class CardStoreUnreadableError extends Error {
|
|
159
|
+
constructor(filePath: string, reason: string) {
|
|
160
|
+
super(
|
|
161
|
+
`Refusing to touch the payment card store at ${filePath}: the file exists but cannot be read (${reason}). `
|
|
162
|
+
+ 'Reading it as empty would report your cards as gone, and writing over it would strand the card material '
|
|
163
|
+
+ 'in the secret store with nothing left pointing at it. Repair the file, or move it aside if you want to '
|
|
164
|
+
+ 'start over, then retry.',
|
|
165
|
+
);
|
|
166
|
+
this.name = 'CardStoreUnreadableError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Three outcomes, never two.
|
|
172
|
+
*
|
|
173
|
+
* This mirrors the SDK's own `readEncryptedStore` (config/secrets.ts) and it
|
|
174
|
+
* mirrors it because that module learned the lesson first: `missing` is a
|
|
175
|
+
* legitimately empty store and `unreadable` is a file whose contents are still
|
|
176
|
+
* there and merely unavailable to us, and collapsing the second into the first
|
|
177
|
+
* is what turns a damaged file into a destroyed one. Here the destruction was
|
|
178
|
+
* two-sided: a `create` against a corrupt file rewrote it with only the new
|
|
179
|
+
* card, and the material of every card it dropped stayed in the secret tier
|
|
180
|
+
* with no row left to reach it by, so `payments.cards.delete` could not clear
|
|
181
|
+
* it either. Unrecoverable through any verb this daemon serves.
|
|
182
|
+
*/
|
|
183
|
+
type CardsReadResult =
|
|
184
|
+
| { readonly status: 'ok'; readonly cards: CardMetadata[] }
|
|
185
|
+
| { readonly status: 'missing' }
|
|
186
|
+
| { readonly status: 'unreadable'; readonly reason: string };
|
|
187
|
+
|
|
188
|
+
function readCardsFile(filePath: string): CardsReadResult {
|
|
189
|
+
if (!existsSync(filePath)) return { status: 'missing' };
|
|
190
|
+
let raw: string;
|
|
191
|
+
try {
|
|
192
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { status: 'unreadable', reason: error instanceof Error ? error.message : 'unreadable' };
|
|
195
|
+
}
|
|
196
|
+
let parsed: unknown;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(raw) as unknown;
|
|
199
|
+
} catch {
|
|
200
|
+
return { status: 'unreadable', reason: 'card file is not valid JSON' };
|
|
201
|
+
}
|
|
202
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
203
|
+
return { status: 'unreadable', reason: 'card file has an unrecognized shape' };
|
|
204
|
+
}
|
|
205
|
+
const cards = (parsed as Partial<CardsFile>).cards;
|
|
206
|
+
if (cards === undefined) {
|
|
207
|
+
// A well-formed document with no `cards` key is an empty store somebody
|
|
208
|
+
// wrote deliberately, not a damaged one. Distinct from a `cards` that is
|
|
209
|
+
// present and the wrong type, which is damage.
|
|
210
|
+
return { status: 'ok', cards: [] };
|
|
211
|
+
}
|
|
212
|
+
if (!Array.isArray(cards)) {
|
|
213
|
+
return { status: 'unreadable', reason: 'card file has an unrecognized shape' };
|
|
214
|
+
}
|
|
215
|
+
return { status: 'ok', cards: [...cards] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeCardsFile(filePath: string, cards: readonly CardMetadata[]): void {
|
|
219
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
220
|
+
const contents: CardsFile = { version: CARDS_FILE_VERSION, cards };
|
|
221
|
+
atomicWriteFileSync(filePath, `${JSON.stringify(contents, null, 2)}\n`, { mode: 0o600 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface CardCreateInput {
|
|
225
|
+
readonly label: string;
|
|
226
|
+
readonly kind: 'virtual' | 'real';
|
|
227
|
+
readonly number: string;
|
|
228
|
+
readonly expiryMonth: number;
|
|
229
|
+
readonly expiryYear: number;
|
|
230
|
+
readonly cvv: string;
|
|
231
|
+
readonly cardholderName: string;
|
|
232
|
+
readonly issuerCapMinorUnits: number | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface DaemonCardStoreOptions {
|
|
236
|
+
readonly filePath: string;
|
|
237
|
+
readonly secrets: PaymentsSecretStore;
|
|
238
|
+
/** Read live, per call: the owner can change it between two card writes. */
|
|
239
|
+
readonly cvvHandling: () => CvvHandling;
|
|
240
|
+
/** Injectable so a test can assert on the id it is about to look up. */
|
|
241
|
+
readonly generateId?: (() => string) | undefined;
|
|
242
|
+
readonly now?: (() => Date) | undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The daemon's card store: metadata on disk, material in the secret tier.
|
|
247
|
+
*
|
|
248
|
+
* Implements the SDK's `CardMaterialStore` so the checkout flow can be handed
|
|
249
|
+
* this object unchanged the day `payments.checkout.*` is wired to a page driver.
|
|
250
|
+
*/
|
|
251
|
+
export class DaemonCardStore implements CardMaterialStore {
|
|
252
|
+
private readonly filePath: string;
|
|
253
|
+
private readonly secrets: PaymentsSecretStore;
|
|
254
|
+
private readonly cvvHandling: () => CvvHandling;
|
|
255
|
+
private readonly generateId: () => string;
|
|
256
|
+
private readonly now: () => Date;
|
|
257
|
+
|
|
258
|
+
constructor(options: DaemonCardStoreOptions) {
|
|
259
|
+
this.filePath = options.filePath;
|
|
260
|
+
this.secrets = options.secrets;
|
|
261
|
+
this.cvvHandling = options.cvvHandling;
|
|
262
|
+
this.generateId = options.generateId ?? newCardId;
|
|
263
|
+
this.now = options.now ?? ((): Date => new Date());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Every configured card, oldest first. Metadata only; there is no other shape.
|
|
268
|
+
*
|
|
269
|
+
* Raises `CardStoreUnreadableError` when the file is present and damaged,
|
|
270
|
+
* rather than answering "no cards". "Your cards are gone" and "I cannot read
|
|
271
|
+
* the file your cards are in" are different sentences and only one of them is
|
|
272
|
+
* ever true here.
|
|
273
|
+
*/
|
|
274
|
+
list(): readonly CardMetadata[] {
|
|
275
|
+
return this.readOrRaise().cards;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async metadata(cardId: string): Promise<CardMetadata | null> {
|
|
279
|
+
return this.list().find((card) => card.id === cardId) ?? null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Whether the store can currently SUPPLY every field `read()` requires.
|
|
284
|
+
*
|
|
285
|
+
* Two sources, and neither is the metadata file: the secret tier, so a card
|
|
286
|
+
* whose material was cleared out from under it reports incomplete rather than
|
|
287
|
+
* reporting a card that cannot be typed; and the live `payments.cvvHandling`,
|
|
288
|
+
* see `suppliesStoredCvv`.
|
|
289
|
+
*/
|
|
290
|
+
async materialComplete(cardId: string): Promise<boolean> {
|
|
291
|
+
if ((await this.metadata(cardId)) === null) return false;
|
|
292
|
+
for (const field of CARD_MATERIAL_FIELDS) {
|
|
293
|
+
if (await this.fieldValue(cardId, field) === null) return false;
|
|
294
|
+
}
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The material. In-process callers only, and all-or-nothing.
|
|
300
|
+
*
|
|
301
|
+
* Never logged, never cached beyond the call, and never part of a thrown
|
|
302
|
+
* error, which is the contract `CardMaterialStore` states and the reason this
|
|
303
|
+
* method returns null instead of raising anything descriptive. The one thing
|
|
304
|
+
* it does raise is `CardStoreUnreadableError`, from `metadata`, and that is
|
|
305
|
+
* deliberate: a damaged card file means this store cannot tell whether the
|
|
306
|
+
* card is configured at all, and null would claim it had checked.
|
|
307
|
+
*/
|
|
308
|
+
async read(cardId: string): Promise<CardMaterial | null> {
|
|
309
|
+
if ((await this.metadata(cardId)) === null) return null;
|
|
310
|
+
const values: Partial<Record<CardMaterialField, string>> = {};
|
|
311
|
+
for (const field of CARD_MATERIAL_FIELDS) {
|
|
312
|
+
const value = await this.fieldValue(cardId, field);
|
|
313
|
+
if (value === null) return null;
|
|
314
|
+
values[field] = value;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
number: values.number!,
|
|
318
|
+
expiryMonth: values.expiryMonth!,
|
|
319
|
+
expiryYear: values.expiryYear!,
|
|
320
|
+
cvv: values.cvv!,
|
|
321
|
+
cardholderName: values.cardholderName!,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Store a card: the metadata ROW first, the material second.
|
|
327
|
+
*
|
|
328
|
+
* Both orders leave a window, and they leave different ones. Material first
|
|
329
|
+
* means a crash between the two strands key material with no row pointing at
|
|
330
|
+
* it: invisible to `payments.cards.list` and therefore unreachable by
|
|
331
|
+
* `payments.cards.delete`, which resolves the id through the row. Row first
|
|
332
|
+
* means a crash leaves a card the owner can SEE, reporting
|
|
333
|
+
* `materialComplete: false`, and can delete, which sweeps the material that
|
|
334
|
+
* did land. A visible half-written card beats invisible key material, so the
|
|
335
|
+
* row goes first.
|
|
336
|
+
*
|
|
337
|
+
* A failure this method actually catches is unwound completely: the secrets
|
|
338
|
+
* that landed are cleared and the row is taken back out, so a reported failure
|
|
339
|
+
* leaves nothing behind. Only a crash can leave the intermediate state, and
|
|
340
|
+
* that state is the recoverable one by construction.
|
|
341
|
+
*/
|
|
342
|
+
async create(input: CardCreateInput): Promise<CardMetadata> {
|
|
343
|
+
const digits = input.number.replace(/\D/g, '');
|
|
344
|
+
const cardId = this.generateId();
|
|
345
|
+
const storeCvv = this.cvvHandling() === 'stored';
|
|
346
|
+
|
|
347
|
+
// Reads before it writes, so a damaged file refuses HERE, before any secret
|
|
348
|
+
// is written and before the existing rows could be dropped.
|
|
349
|
+
const existing = this.readOrRaise().cards;
|
|
350
|
+
const metadata: CardMetadata = {
|
|
351
|
+
id: cardId,
|
|
352
|
+
label: input.label,
|
|
353
|
+
brand: cardBrand(digits),
|
|
354
|
+
last4: digits.slice(-4),
|
|
355
|
+
kind: input.kind,
|
|
356
|
+
expiryMonth: input.expiryMonth,
|
|
357
|
+
expiryYear: input.expiryYear,
|
|
358
|
+
issuerCapMinorUnits: input.issuerCapMinorUnits,
|
|
359
|
+
addedAt: this.now().toISOString(),
|
|
360
|
+
};
|
|
361
|
+
writeCardsFile(this.filePath, [...existing, metadata]);
|
|
362
|
+
|
|
363
|
+
const written: CardMaterialField[] = [];
|
|
364
|
+
try {
|
|
365
|
+
// The NORMALIZED digits, not what was typed. Separators are a data-entry
|
|
366
|
+
// convenience and a checkout field is not: `fillCard` types this value
|
|
367
|
+
// verbatim, and "4111-1111 1111-1111" in a card-number input is a
|
|
368
|
+
// declined order. The owner's spacing is not information worth keeping.
|
|
369
|
+
await this.putField(cardId, 'number', digits, written);
|
|
370
|
+
await this.putField(cardId, 'expiryMonth', String(input.expiryMonth).padStart(2, '0'), written);
|
|
371
|
+
await this.putField(cardId, 'expiryYear', String(input.expiryYear), written);
|
|
372
|
+
// Not written at all under 'prompt'. See the header: the card then reports
|
|
373
|
+
// materialComplete: false, which is the truth and the rendered state.
|
|
374
|
+
if (storeCvv) await this.putField(cardId, 'cvv', input.cvv, written);
|
|
375
|
+
await this.putField(cardId, 'cardholderName', input.cardholderName, written);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
await this.clearFields(cardId, written);
|
|
378
|
+
this.dropRow(cardId);
|
|
379
|
+
// Rethrown as a message written HERE. The original came from a call whose
|
|
380
|
+
// arguments were the card.
|
|
381
|
+
void error;
|
|
382
|
+
throw new Error('The card could not be written to the daemon secret store.');
|
|
383
|
+
}
|
|
384
|
+
return metadata;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Delete a card and every secret derived from its id.
|
|
389
|
+
*
|
|
390
|
+
* `secretsCleared` counts the keys that actually held something, so a partial
|
|
391
|
+
* deletion is visible rather than silent, which is what the descriptor for
|
|
392
|
+
* `payments.cards.delete` promises.
|
|
393
|
+
*
|
|
394
|
+
* The material goes first and the row LAST, which is `create` read backwards
|
|
395
|
+
* and the same principle: a crash between the two leaves a visible row the
|
|
396
|
+
* owner can delete again, never material with no row pointing at it. For the
|
|
397
|
+
* same reason the sweep runs even when there is no row to remove, so material
|
|
398
|
+
* a crashed `create` stranded can still be cleared by asking for its id.
|
|
399
|
+
*
|
|
400
|
+
* Reads the file before it deletes anything, so a damaged store refuses here
|
|
401
|
+
* rather than clearing material it is about to lose the row for.
|
|
402
|
+
*/
|
|
403
|
+
async remove(cardId: string): Promise<{ deleted: boolean; secretsCleared: number }> {
|
|
404
|
+
const cards = this.list();
|
|
405
|
+
const remaining = cards.filter((card) => card.id !== cardId);
|
|
406
|
+
let secretsCleared = 0;
|
|
407
|
+
for (const field of CARD_MATERIAL_FIELDS) {
|
|
408
|
+
const key = cardSecretKey(cardId, field);
|
|
409
|
+
const value = await this.secrets.get(key);
|
|
410
|
+
if (value === null) continue;
|
|
411
|
+
await this.secrets.delete(key);
|
|
412
|
+
secretsCleared += 1;
|
|
413
|
+
}
|
|
414
|
+
const deleted = remaining.length !== cards.length;
|
|
415
|
+
if (deleted) writeCardsFile(this.filePath, remaining);
|
|
416
|
+
return { deleted, secretsCleared };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The file's contents, or the refusal. The one read path; nothing else parses. */
|
|
420
|
+
private readOrRaise(): { cards: CardMetadata[] } {
|
|
421
|
+
const result = readCardsFile(this.filePath);
|
|
422
|
+
if (result.status === 'unreadable') {
|
|
423
|
+
throw new CardStoreUnreadableError(this.filePath, result.reason);
|
|
424
|
+
}
|
|
425
|
+
return { cards: result.status === 'ok' ? result.cards : [] };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Take one row back out, used only to unwind a `create` that failed. */
|
|
429
|
+
private dropRow(cardId: string): void {
|
|
430
|
+
try {
|
|
431
|
+
writeCardsFile(this.filePath, this.readOrRaise().cards.filter((card) => card.id !== cardId));
|
|
432
|
+
} catch {
|
|
433
|
+
// Best effort, for the same reason clearFields is: the write that failed
|
|
434
|
+
// is already being reported, and a failure to unwind it must not replace
|
|
435
|
+
// that report with a second one. What survives is a visible row with
|
|
436
|
+
// materialComplete false, which is the recoverable state by design.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Whether the store may hand back a STORED CVV right now.
|
|
442
|
+
*
|
|
443
|
+
* `payments.cvvHandling: 'prompt'` is the owner saying the CVV must not be
|
|
444
|
+
* kept, and honoring that only at write time would have made it a statement
|
|
445
|
+
* about new cards rather than a setting: a card added under 'stored' went on
|
|
446
|
+
* being readable, and went on reporting complete, after the flip. So the check
|
|
447
|
+
* is HERE, on every supply path, and the setting means what it says whenever
|
|
448
|
+
* it is read.
|
|
449
|
+
*
|
|
450
|
+
* The stored bytes are deliberately NOT purged when the setting flips.
|
|
451
|
+
* Flipping back restores the card intact, and destroying the owner's stored
|
|
452
|
+
* data as a side effect of changing a preference is not a decision this module
|
|
453
|
+
* gets to make; a purge is `payments.cards.delete`, which the owner asks for.
|
|
454
|
+
*/
|
|
455
|
+
private suppliesStoredCvv(): boolean {
|
|
456
|
+
return this.cvvHandling() === 'stored';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** One field, or null when it is absent or currently not suppliable. */
|
|
460
|
+
private async fieldValue(cardId: string, field: CardMaterialField): Promise<string | null> {
|
|
461
|
+
if (field === 'cvv' && !this.suppliesStoredCvv()) return null;
|
|
462
|
+
const value = await this.secrets.get(cardSecretKey(cardId, field));
|
|
463
|
+
return value === null || value.length === 0 ? null : value;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async putField(
|
|
467
|
+
cardId: string,
|
|
468
|
+
field: CardMaterialField,
|
|
469
|
+
value: string,
|
|
470
|
+
written: CardMaterialField[],
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
await this.secrets.set(cardSecretKey(cardId, field), value);
|
|
473
|
+
written.push(field);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async clearFields(cardId: string, fields: readonly CardMaterialField[]): Promise<void> {
|
|
477
|
+
for (const field of fields) {
|
|
478
|
+
try {
|
|
479
|
+
await this.secrets.delete(cardSecretKey(cardId, field));
|
|
480
|
+
} catch {
|
|
481
|
+
// Best effort. The write that failed is already being reported; a
|
|
482
|
+
// failure to undo it must not replace that report with a second one.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|