behavior-wrapped 0.2.14 → 0.2.16

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/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <meta name="theme-color" content="#0d0b1b" />
7
7
  <meta name="description" content="Your private, local-first Claude Code behavior report." />
8
8
  <title>Behavior Wrapped</title>
9
- <script type="module" crossorigin src="/assets/index-yvo-hzIv.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BIbVOfPJ.css">
9
+ <script type="module" crossorigin src="/assets/index-DspaiSuT.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BGWEDZlc.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "A private, local-first Wrapped report for Claude Code and Codex behavior.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "analyze:phrases": "node scripts/mine-phrase-families.mjs",
45
45
  "review:interactions": "node scripts/review-interaction-tone.mjs",
46
46
  "review:workarounds": "node scripts/review-workaround-judge.mjs",
47
+ "research:decrypt": "node scripts/decrypt-research-donation.mjs",
47
48
  "worker:dev": "npx --yes wrangler@4.86.0 dev",
48
49
  "worker:deploy": "npx --yes wrangler@4.86.0 deploy",
49
50
  "test": "node --test tests/*.test.mjs",
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { decryptResearchDonation } from "../server/research-donation-crypto.mjs";
7
+
8
+ const [, , inputArg, outputArg, keyArg] = process.argv;
9
+ if (!inputArg || !outputArg) {
10
+ console.error("Usage: node scripts/decrypt-research-donation.mjs <encrypted-envelope.json> <private-output.json> [private-key.pem]");
11
+ process.exit(1);
12
+ }
13
+
14
+ const input = path.resolve(inputArg);
15
+ const output = path.resolve(outputArg);
16
+ const privateKeyPath = path.resolve(keyArg || path.join(os.homedir(), ".config", "behavior-wrapped", "keys", "research-donation-rsa-2026-08.pem"));
17
+ const passphrase = process.env.BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE || (process.platform === "darwin"
18
+ ? execFileSync("security", ["find-generic-password", "-a", os.userInfo().username, "-s", "behavior-wrapped-research-key-2026-08", "-w"], { encoding: "utf8" }).trim()
19
+ : "");
20
+
21
+ if (!passphrase) throw new Error("Set BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE before decrypting.");
22
+ const envelope = JSON.parse(fs.readFileSync(input, "utf8"));
23
+ const donation = decryptResearchDonation(envelope, fs.readFileSync(privateKeyPath, "utf8"), passphrase);
24
+ fs.writeFileSync(output, `${JSON.stringify(donation, null, 2)}\n`, { mode: 0o600, flag: "wx" });
25
+ console.log(`Decrypted donation written with private permissions: ${output}`);
@@ -485,14 +485,16 @@ function donationSessionSummary(messages, suppliedSummary) {
485
485
  return `${shortened.slice(0, Math.max(shortened.lastIndexOf(" "), 1)).trim()}…`;
486
486
  }
487
487
 
488
- export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [] } = {}) {
488
+ export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [], unredacted = false } = {}) {
489
489
  const detections = [];
490
490
  const sessions = sessionRecords.map(({ sessionId, records }) => {
491
491
  const messages = records.flatMap((record) => {
492
492
  if (record.type !== "user" && record.type !== "assistant") return [];
493
493
  const value = visibleText(record);
494
494
  if (!value) return [];
495
- const redacted = redactText(value, [], { disabledKinds: disabledRedactions, disabledMatches, includeHeuristicSecrets: false });
495
+ const redacted = unredacted
496
+ ? { text: value, detections: [] }
497
+ : redactText(value, [], { disabledKinds: disabledRedactions, disabledMatches, includeHeuristicSecrets: false });
496
498
  detections.push(...redacted.detections);
497
499
  return [{ role: record.type, timestamp: record.timestamp || null, text: redacted.text }];
498
500
  });
@@ -501,5 +503,5 @@ export function makeDonationPreview(sessionRecords, metadataById, { disabledReda
501
503
  });
502
504
  const redactions = donationRedactionInventory(detections);
503
505
  const detectionCount = detections.filter((detection) => detection.enabled !== false).length;
504
- return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, detectionCount, redactions, sessions };
506
+ return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, unredacted, detectionCount, redactions, sessions };
505
507
  }
@@ -0,0 +1,45 @@
1
+ import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
2
+
3
+ export const DONATION_ENVELOPE_FORMAT = "behavior-wrapped-encrypted-donation-v1";
4
+ export const DONATION_ENCRYPTION_ALGORITHM = "RSA-OAEP-256+A256GCM";
5
+ export const DONATION_KEY_ID = "research-donation-rsa-2026-08";
6
+ export const MAX_ENCRYPTED_DONATION_BYTES = 2_500_000;
7
+
8
+ const base64url = /^[A-Za-z0-9_-]+$/;
9
+ const timestamp = /^\d{4}-\d{2}-\d{2}T/;
10
+
11
+ function exactKeys(value, expected) {
12
+ return value && typeof value === "object" && !Array.isArray(value)
13
+ && Object.keys(value).sort().join("|") === [...expected].sort().join("|");
14
+ }
15
+
16
+ function boundedInteger(value, maximum) {
17
+ return Number.isInteger(value) && value >= 0 && value <= maximum;
18
+ }
19
+
20
+ export function encryptedDonationAAD(envelope) {
21
+ return JSON.stringify({
22
+ format: envelope.format,
23
+ encryption: { algorithm: envelope.encryption.algorithm, keyId: envelope.encryption.keyId },
24
+ metadata: envelope.metadata,
25
+ });
26
+ }
27
+
28
+ export function sanitizeEncryptedDonationEnvelope(value) {
29
+ if (!exactKeys(value, ["ciphertext", "encryption", "format", "metadata"])) return null;
30
+ if (value.format !== DONATION_ENVELOPE_FORMAT) return null;
31
+ if (!exactKeys(value.encryption, ["algorithm", "authTag", "iv", "keyId", "wrappedKey"])) return null;
32
+ if (value.encryption.algorithm !== DONATION_ENCRYPTION_ALGORITHM || value.encryption.keyId !== DONATION_KEY_ID) return null;
33
+ if (typeof value.encryption.wrappedKey !== "string" || value.encryption.wrappedKey.length < 480 || value.encryption.wrappedKey.length > 700 || !base64url.test(value.encryption.wrappedKey)) return null;
34
+ if (typeof value.encryption.iv !== "string" || value.encryption.iv.length !== 16 || !base64url.test(value.encryption.iv)) return null;
35
+ if (typeof value.encryption.authTag !== "string" || value.encryption.authTag.length !== 22 || !base64url.test(value.encryption.authTag)) return null;
36
+ if (typeof value.ciphertext !== "string" || !value.ciphertext.length || value.ciphertext.length > Math.ceil(MAX_DONATION_BYTES / 3) * 4 || !base64url.test(value.ciphertext)) return null;
37
+ if (!exactKeys(value.metadata, ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"])) return null;
38
+ const metadata = value.metadata;
39
+ if (!/^[A-Za-z0-9_-]{8,32}$/.test(metadata.reportId || "")) return null;
40
+ if (!new Set(["standard", "custom", "unredacted"]).has(metadata.redactionMode)) return null;
41
+ if (!timestamp.test(metadata.createdAt || "") || !timestamp.test(metadata.consentedAt || "")) return null;
42
+ if (metadata.consentVersion !== 1 || typeof metadata.unredactedData !== "boolean" || metadata.unredactedData !== (metadata.redactionMode === "unredacted")) return null;
43
+ if (!boundedInteger(metadata.automatedDetections, 1_000_000) || !boundedInteger(metadata.sessions, 250) || metadata.sessions < 1 || !boundedInteger(metadata.messages, 50_000) || metadata.messages < 1) return null;
44
+ return value;
45
+ }
@@ -7,8 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { spawn } from "node:child_process";
8
8
  import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
9
9
  import { makeDonationPreview } from "./analysis.mjs";
10
- import { getOrCreateClientId, loadReport } from "./store.mjs";
11
- import { RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
10
+ import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
11
+ import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
12
12
 
13
13
  const here = path.dirname(fileURLToPath(import.meta.url));
14
14
  const root = path.dirname(here);
@@ -121,7 +121,8 @@ const server = http.createServer(async (request, response) => {
121
121
  const labels = new Map(publicCatalog().sessions.map((session) => [session.id, { ...session, summary: summaries.get(session.id) }]));
122
122
  const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
123
123
  const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
124
- return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches }));
124
+ const unredacted = body.previewMode === "unredacted";
125
+ return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches, unredacted }));
125
126
  }
126
127
  if (request.method === "POST" && url.pathname === "/api/research-donations") {
127
128
  const body = await readBody(request, 4_200_000);
@@ -132,7 +133,17 @@ const server = http.createServer(async (request, response) => {
132
133
  clientId: getOrCreateClientId(),
133
134
  endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
134
135
  });
135
- return json(response, 201, result);
136
+ saveDonationReceipt(result);
137
+ return json(response, 201, { accepted: true, donation_id: result.donation_id, encrypted: true });
138
+ }
139
+ const donationMatch = url.pathname.match(/^\/api\/research-donations\/([0-9a-f-]{36})$/);
140
+ if (request.method === "DELETE" && donationMatch) {
141
+ const receipt = loadDonationReceipt(donationMatch[1]);
142
+ if (!receipt) return json(response, 404, { error: "Local deletion receipt not found." });
143
+ if (demo) { deleteDonationReceipt(donationMatch[1]); return json(response, 200, { deleted: true, demo: true }); }
144
+ const result = await deleteResearchDonation(receipt.donationId, receipt.deletionToken, { endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL });
145
+ deleteDonationReceipt(donationMatch[1]);
146
+ return json(response, 200, result);
136
147
  }
137
148
  if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: "Method not allowed" });
138
149
  const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
@@ -0,0 +1,68 @@
1
+ import crypto from "node:crypto";
2
+ import { DONATION_ENCRYPTION_ALGORITHM, DONATION_ENVELOPE_FORMAT, DONATION_KEY_ID, encryptedDonationAAD, sanitizeEncryptedDonationEnvelope } from "./encrypted-donation-schema.mjs";
3
+ import { sanitizeResearchDonation } from "./research-donation-schema.mjs";
4
+
5
+ export const RESEARCH_DONATION_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
6
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA0RaXFQBixAmtwKRz2I7Y
7
+ pq0TlQPAZ72gHbyV2RJ9dRiDNwlwaKbHHVLYHG0QjxFanktNi/ms6NoV9slBQhjJ
8
+ Rb3kMzg5xEJYYy8TzyQKpY28f5/srGpSL2ziWRb9TSsgrOJPNk9LFPKLuJhty1+x
9
+ Gh9+I3UW+JPj+To4VY7GVU46jptP2MDtROK5v/p9PLP+QoKhjTBuDqgu5T78wTv5
10
+ /C34ZZD4ACIKvIQ8dtAZM6CPY0sWWVN84VO5etr1rYZg7DWczy2ZsX2StiKmuZ8b
11
+ kSqZr/mn6+PC5sthPCt0B+Tk1pxPv6LuwiNYubst4EKQDpFbP2e4h3KIaNxTnVRx
12
+ AHzd9XfF9rN86+Cjf55YlBuT9GeYXLttGBfoT6Llr4Xw370WIHabo7A57/atLOgw
13
+ YKX6TcNe6TOHuCTHM7LDmTPGNZdMi9cXXBUzohvTxm7O8qduIekFg4emxIiduVY2
14
+ 3pHhzoP9p0kwO+L0BE1ELIHF9dk0p3s/NDIDfbiDxn5XAgMBAAE=
15
+ -----END PUBLIC KEY-----`;
16
+
17
+ function base64url(value) {
18
+ return Buffer.from(value).toString("base64url");
19
+ }
20
+
21
+ export function encryptResearchDonation(value, publicKey = RESEARCH_DONATION_PUBLIC_KEY) {
22
+ const donation = sanitizeResearchDonation(value);
23
+ if (!donation) throw new Error("The reviewed donation does not match the research schema.");
24
+ const plaintext = Buffer.from(JSON.stringify(donation));
25
+ const contentKey = crypto.randomBytes(32);
26
+ const iv = crypto.randomBytes(12);
27
+ const envelope = {
28
+ format: DONATION_ENVELOPE_FORMAT,
29
+ encryption: { algorithm: DONATION_ENCRYPTION_ALGORITHM, keyId: DONATION_KEY_ID },
30
+ metadata: {
31
+ reportId: donation.reportId,
32
+ redactionMode: donation.redactionMode,
33
+ createdAt: donation.createdAt,
34
+ consentedAt: donation.consent.consentedAt,
35
+ consentVersion: 1,
36
+ unredactedData: donation.redactionMode === "unredacted",
37
+ automatedDetections: donation.redactionSummary.automatedDetections,
38
+ sessions: donation.redactionSummary.sessions,
39
+ messages: donation.redactionSummary.messages,
40
+ },
41
+ };
42
+ const cipher = crypto.createCipheriv("aes-256-gcm", contentKey, iv);
43
+ cipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
44
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
45
+ return sanitizeEncryptedDonationEnvelope({
46
+ ...envelope,
47
+ encryption: {
48
+ ...envelope.encryption,
49
+ wrappedKey: base64url(crypto.publicEncrypt({ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, contentKey)),
50
+ iv: base64url(iv),
51
+ authTag: base64url(cipher.getAuthTag()),
52
+ },
53
+ ciphertext: base64url(ciphertext),
54
+ });
55
+ }
56
+
57
+ export function decryptResearchDonation(value, privateKey, passphrase) {
58
+ const envelope = sanitizeEncryptedDonationEnvelope(value);
59
+ if (!envelope) throw new Error("The encrypted donation envelope is invalid.");
60
+ const contentKey = crypto.privateDecrypt({ key: privateKey, passphrase, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(envelope.encryption.wrappedKey, "base64url"));
61
+ const decipher = crypto.createDecipheriv("aes-256-gcm", contentKey, Buffer.from(envelope.encryption.iv, "base64url"));
62
+ decipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
63
+ decipher.setAuthTag(Buffer.from(envelope.encryption.authTag, "base64url"));
64
+ const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64url")), decipher.final()]);
65
+ const donation = sanitizeResearchDonation(JSON.parse(plaintext.toString("utf8")));
66
+ if (!donation) throw new Error("The decrypted donation is invalid.");
67
+ return donation;
68
+ }
@@ -1,4 +1,4 @@
1
- const MAX_DONATION_BYTES = 4_000_000;
1
+ const MAX_DONATION_BYTES = 1_800_000;
2
2
  const MAX_SESSIONS = 250;
3
3
  const MAX_MESSAGES = 50_000;
4
4
  const MAX_MESSAGE_LENGTH = 20_000;
@@ -11,7 +11,9 @@ export function sanitizeResearchDonation(value) {
11
11
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
12
12
  if (value.consent?.researchDonation !== true) return null;
13
13
  if (!/^[A-Za-z0-9_-]{8,32}$/.test(value.reportId || "")) return null;
14
- if (!new Set(["standard", "custom"]).has(value.redactionMode)) return null;
14
+ if (!new Set(["standard", "custom", "unredacted"]).has(value.redactionMode)) return null;
15
+ const unredacted = value.redactionMode === "unredacted";
16
+ if (unredacted && value.consent?.unredactedData !== true) return null;
15
17
  if (!Array.isArray(value.sessions) || !value.sessions.length || value.sessions.length > MAX_SESSIONS) return null;
16
18
  let messageCount = 0;
17
19
  const sessions = value.sessions.flatMap((session, sessionIndex) => {
@@ -33,18 +35,21 @@ export function sanitizeResearchDonation(value) {
33
35
  redactionMode: value.redactionMode,
34
36
  createdAt: /^\d{4}-\d{2}-\d{2}T/.test(value.createdAt || "") ? value.createdAt : new Date().toISOString(),
35
37
  redactionSummary: {
36
- automatedDetections: Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
38
+ automatedDetections: unredacted ? 0 : Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
37
39
  sessions: sessions.length,
38
40
  messages: messageCount,
39
41
  },
40
42
  sessions,
41
43
  consent: {
42
44
  researchDonation: true,
43
- statement: "I consent for this reviewed data to be transmitted and used for research.",
45
+ ...(unredacted ? { unredactedData: true } : {}),
46
+ statement: unredacted
47
+ ? "I understand this donation is not automatically redacted and may contain credentials, personal details, private code, URLs, and file paths. I consent to transmit it for research."
48
+ : "I consent for this reviewed data to be transmitted and used for research.",
44
49
  consentedAt: /^\d{4}-\d{2}-\d{2}T/.test(value.consent.consentedAt || "") ? value.consent.consentedAt : new Date().toISOString(),
45
50
  },
46
51
  };
47
- return JSON.stringify(donation).length <= MAX_DONATION_BYTES ? donation : null;
52
+ return new TextEncoder().encode(JSON.stringify(donation)).byteLength <= MAX_DONATION_BYTES ? donation : null;
48
53
  }
49
54
 
50
55
  export { MAX_DONATION_BYTES };
@@ -1,22 +1,21 @@
1
- import { sanitizeResearchDonation } from "./research-donation-schema.mjs";
1
+ import { encryptResearchDonation } from "./research-donation-crypto.mjs";
2
2
 
3
3
  export const RESEARCH_DONATION_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/research-donations";
4
4
  const REQUEST_TIMEOUT_MS = 30_000;
5
5
 
6
6
  export async function submitResearchDonation(value, { clientId, endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
7
- const donation = sanitizeResearchDonation(value);
8
- if (!donation) throw new Error("The reviewed donation does not match the research schema.");
7
+ const encryptedDonation = encryptResearchDonation(value);
9
8
  let response;
10
9
  try {
11
10
  response = await fetchImpl(endpoint, {
12
11
  method: "POST",
13
12
  headers: {
14
13
  "content-type": "application/json",
15
- "x-behavior-wrapped-protocol": "1",
14
+ "x-behavior-wrapped-protocol": "2",
16
15
  ...(clientId ? { "x-behavior-wrapped-client": clientId } : {}),
17
16
  },
18
17
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
19
- body: JSON.stringify({ donation }),
18
+ body: JSON.stringify({ encryptedDonation }),
20
19
  });
21
20
  } catch (error) {
22
21
  if (error?.name === "TimeoutError" || error?.name === "AbortError") throw new Error("The research donation timed out. Your data was not confirmed as received.");
@@ -26,3 +25,16 @@ export async function submitResearchDonation(value, { clientId, endpoint = RESEA
26
25
  if (!response.ok) throw new Error(body?.error || "The research donation could not be accepted.");
27
26
  return body;
28
27
  }
28
+
29
+ export async function deleteResearchDonation(id, deletionToken, { endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
30
+ if (!/^[0-9a-f-]{36}$/.test(id || "") || !/^[A-Za-z0-9_-]{43}$/.test(deletionToken || "")) throw new Error("The local deletion receipt is invalid.");
31
+ const response = await fetchImpl(`${endpoint}/${id}`, {
32
+ method: "DELETE",
33
+ headers: { "x-behavior-wrapped-protocol": "2", "x-behavior-wrapped-deletion-token": deletionToken },
34
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
35
+ }).catch(() => null);
36
+ if (!response) throw new Error("The research donation service is temporarily unavailable.");
37
+ const body = await response.json().catch(() => ({}));
38
+ if (!response.ok) throw new Error(body?.error || "The research donation could not be deleted.");
39
+ return body;
40
+ }
package/server/store.mjs CHANGED
@@ -5,10 +5,34 @@ import crypto from "node:crypto";
5
5
 
6
6
  export const storeRoot = process.env.BEHAVIOR_WRAPPED_STORE_ROOT || path.join(os.homedir(), ".agent-behavior-wrapped");
7
7
  export const reportsRoot = path.join(storeRoot, "reports");
8
+ export const donationReceiptsRoot = path.join(storeRoot, "donation-receipts");
8
9
  const clientIdFile = path.join(storeRoot, "client-id");
9
10
 
10
11
  function ensureStore() {
11
12
  fs.mkdirSync(reportsRoot, { recursive: true, mode: 0o700 });
13
+ fs.mkdirSync(donationReceiptsRoot, { recursive: true, mode: 0o700 });
14
+ }
15
+
16
+ export function saveDonationReceipt(value) {
17
+ if (!/^[0-9a-f-]{36}$/.test(value?.donation_id || "") || !/^[A-Za-z0-9_-]{43}$/.test(value?.deletion_token || "")) throw new Error("The donation service returned an invalid deletion receipt.");
18
+ ensureStore();
19
+ const receipt = { donationId: value.donation_id, deletionToken: value.deletion_token, savedAt: new Date().toISOString() };
20
+ fs.writeFileSync(path.join(donationReceiptsRoot, `${receipt.donationId}.json`), `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: "wx" });
21
+ return receipt;
22
+ }
23
+
24
+ export function loadDonationReceipt(id) {
25
+ if (!/^[0-9a-f-]{36}$/.test(id || "")) return null;
26
+ try { return JSON.parse(fs.readFileSync(path.join(donationReceiptsRoot, `${id}.json`), "utf8")); }
27
+ catch { return null; }
28
+ }
29
+
30
+ export function deleteDonationReceipt(id) {
31
+ if (!/^[0-9a-f-]{36}$/.test(id || "")) return false;
32
+ const file = path.join(donationReceiptsRoot, `${id}.json`);
33
+ if (!fs.existsSync(file)) return false;
34
+ fs.unlinkSync(file);
35
+ return true;
12
36
  }
13
37
 
14
38
  export function createReportId() {