@chainlesschain/personal-data-hub 0.1.0

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.
Files changed (50) hide show
  1. package/README.md +241 -0
  2. package/__tests__/adapter-spec.test.js +78 -0
  3. package/__tests__/adapters/email-adapter.test.js +605 -0
  4. package/__tests__/adapters/email-imap-session.test.js +334 -0
  5. package/__tests__/adapters/email-parser.test.js +244 -0
  6. package/__tests__/adapters/email-providers.test.js +84 -0
  7. package/__tests__/analysis.test.js +302 -0
  8. package/__tests__/batch.test.js +133 -0
  9. package/__tests__/bridges-cc-kg.test.js +231 -0
  10. package/__tests__/bridges-cc-llm.test.js +191 -0
  11. package/__tests__/bridges-cc-rag.test.js +162 -0
  12. package/__tests__/ids.test.js +45 -0
  13. package/__tests__/key-providers.test.js +126 -0
  14. package/__tests__/kg-derive.test.js +219 -0
  15. package/__tests__/llm-client.test.js +122 -0
  16. package/__tests__/mock-adapter.test.js +93 -0
  17. package/__tests__/prompt-builder.test.js +204 -0
  18. package/__tests__/query-parser.test.js +150 -0
  19. package/__tests__/rag-derive.test.js +169 -0
  20. package/__tests__/registry.test.js +304 -0
  21. package/__tests__/schemas.test.js +331 -0
  22. package/__tests__/vault.test.js +506 -0
  23. package/lib/adapter-spec.js +155 -0
  24. package/lib/adapters/email-imap/email-adapter.js +398 -0
  25. package/lib/adapters/email-imap/email-parser.js +177 -0
  26. package/lib/adapters/email-imap/imap-session.js +294 -0
  27. package/lib/adapters/email-imap/index.js +26 -0
  28. package/lib/adapters/email-imap/providers.js +111 -0
  29. package/lib/analysis.js +226 -0
  30. package/lib/batch.js +123 -0
  31. package/lib/bridges/cc-kg-sink.js +264 -0
  32. package/lib/bridges/cc-llm-adapter.js +169 -0
  33. package/lib/bridges/cc-rag-sink.js +118 -0
  34. package/lib/bridges/index.js +44 -0
  35. package/lib/constants.js +92 -0
  36. package/lib/ids.js +103 -0
  37. package/lib/index.js +141 -0
  38. package/lib/key-providers.js +146 -0
  39. package/lib/kg-derive.js +214 -0
  40. package/lib/llm-client.js +171 -0
  41. package/lib/migrations.js +246 -0
  42. package/lib/mock-adapter.js +199 -0
  43. package/lib/prompt-builder.js +205 -0
  44. package/lib/query-parser.js +250 -0
  45. package/lib/rag-derive.js +186 -0
  46. package/lib/registry.js +398 -0
  47. package/lib/schemas.js +379 -0
  48. package/lib/vault.js +883 -0
  49. package/package.json +63 -0
  50. package/vitest.config.js +10 -0
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Production bridges — wire the hub's pluggable sinks to ChainlessChain's
3
+ * existing LLM / KG / RAG infrastructure.
4
+ *
5
+ * These adapters use dependency injection (caller supplies addEntity /
6
+ * addRelation / chat / addDocument) so the hub package stays decoupled
7
+ * from cc's module system (cli is ESM, desktop main is CJS, hub is CJS).
8
+ *
9
+ * Typical wiring in desktop-app-vue/src/main:
10
+ *
11
+ * const llmManager = require("./llm/llm-manager").getInstance();
12
+ * const { addEntity, addRelation } = require("../../../packages/cli/src/lib/knowledge-graph");
13
+ * const bm25 = ...;
14
+ * const {
15
+ * CcLLMAdapter, CcKgSink, CcRagSink
16
+ * } = require("@chainlesschain/personal-data-hub/bridges");
17
+ *
18
+ * const llm = new CcLLMAdapter({
19
+ * chat: (m, o) => llmManager.chat(m, o),
20
+ * getActiveProvider: () => llmManager.getActiveProvider(),
21
+ * getActiveModel: () => llmManager.getActiveModel(),
22
+ * });
23
+ * const kgSink = new CcKgSink({ addEntity, addRelation, db });
24
+ * const ragSink = new CcRagSink({ bm25 });
25
+ *
26
+ * const registry = new AdapterRegistry({
27
+ * vault, kgSink: kgSink.write.bind(kgSink), ragSink: ragSink.write.bind(ragSink),
28
+ * });
29
+ * const engine = new AnalysisEngine({ vault, llm });
30
+ */
31
+
32
+ "use strict";
33
+
34
+ const { CcLLMAdapter, LOCAL_PROVIDERS } = require("./cc-llm-adapter");
35
+ const { CcKgSink, HUB_TO_CC_TYPE } = require("./cc-kg-sink");
36
+ const { CcRagSink } = require("./cc-rag-sink");
37
+
38
+ module.exports = {
39
+ CcLLMAdapter,
40
+ LOCAL_PROVIDERS,
41
+ CcKgSink,
42
+ HUB_TO_CC_TYPE,
43
+ CcRagSink,
44
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * UnifiedSchema enums + constants
3
+ *
4
+ * Source of truth for valid subtypes / source.capturedBy / etc.
5
+ * Mirrors §5 of docs/design/Personal_Data_Hub_Architecture.md
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const ENTITY_TYPES = Object.freeze({
11
+ PERSON: "person",
12
+ EVENT: "event",
13
+ PLACE: "place",
14
+ ITEM: "item",
15
+ TOPIC: "topic",
16
+ });
17
+
18
+ const PERSON_SUBTYPES = Object.freeze({
19
+ SELF: "self",
20
+ CONTACT: "contact",
21
+ MERCHANT: "merchant",
22
+ AI_AGENT: "ai-agent",
23
+ UNKNOWN: "unknown",
24
+ });
25
+
26
+ const EVENT_SUBTYPES = Object.freeze({
27
+ // Communication
28
+ MESSAGE: "message",
29
+ AI_MESSAGE: "ai-message",
30
+ CALL: "call",
31
+ POST: "post",
32
+ INTERACTION: "interaction",
33
+
34
+ // Commerce
35
+ ORDER: "order",
36
+ PAYMENT: "payment",
37
+ TRANSFER: "transfer",
38
+ REFUND: "refund",
39
+ INCOME: "income",
40
+ INVESTMENT: "investment",
41
+ REDENVELOPE: "redenvelope",
42
+ UTILITY: "utility",
43
+ CANCELLED: "cancelled",
44
+
45
+ // Movement
46
+ VISIT: "visit",
47
+ TRIP: "trip",
48
+
49
+ // Content
50
+ BROWSE: "browse",
51
+ LIKE: "like",
52
+ MEDIA: "media",
53
+
54
+ // Special
55
+ AI_IMAGE_GENERATION: "ai-image-generation",
56
+
57
+ OTHER: "other",
58
+ });
59
+
60
+ const ITEM_SUBTYPES = Object.freeze({
61
+ PRODUCT: "product",
62
+ MEDIA: "media",
63
+ LINK: "link",
64
+ DOCUMENT: "document",
65
+ OTHER: "other",
66
+ });
67
+
68
+ const CAPTURED_BY = Object.freeze({
69
+ EXPORT: "export",
70
+ API: "api",
71
+ SQLITE: "sqlite",
72
+ ACCESSIBILITY: "accessibility",
73
+ OCR: "ocr",
74
+ MANUAL: "manual",
75
+ });
76
+
77
+ const AMOUNT_DIRECTIONS = Object.freeze({
78
+ IN: "in",
79
+ OUT: "out",
80
+ });
81
+
82
+ const SCHEMA_VERSION = "0.1.0";
83
+
84
+ module.exports = {
85
+ ENTITY_TYPES,
86
+ PERSON_SUBTYPES,
87
+ EVENT_SUBTYPES,
88
+ ITEM_SUBTYPES,
89
+ CAPTURED_BY,
90
+ AMOUNT_DIRECTIONS,
91
+ SCHEMA_VERSION,
92
+ };
package/lib/ids.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Entity ID generation — RFC 9562 UUID v7
3
+ *
4
+ * v7 chosen for time-ordered IDs — first 48 bits are unix timestamp ms,
5
+ * so IDs sort chronologically. This gives:
6
+ * - cheap "events by time window" range queries on the id column
7
+ * - SQLite B-tree locality for sequential inserts (less page splits)
8
+ * - debuggability ("which one came first" without joining on occurredAt)
9
+ *
10
+ * Mirrors design doc §5.1: "id 用 UUID v7:含时间序,方便分页 / 按时间窗扫描".
11
+ *
12
+ * Hand-rolled (no `uuid` dep) because the repo currently ships uuid@9 which
13
+ * lacks v7. Algorithm follows RFC 9562 §5.7. ~30 LOC. No external state.
14
+ */
15
+
16
+ "use strict";
17
+
18
+ const { randomBytes } = require("node:crypto");
19
+
20
+ /**
21
+ * Generate a UUID v7.
22
+ *
23
+ * Layout (16 bytes):
24
+ * bytes 0-5: 48-bit unix_ts_ms (big-endian)
25
+ * byte 6: 0111 xxxx (version 7 + 4 rand_a bits)
26
+ * byte 7: xxxx xxxx (8 more rand_a bits)
27
+ * byte 8: 10xx xxxx (variant + 6 rand_b bits)
28
+ * bytes 9-15: 7 rand_b bytes
29
+ */
30
+ function newId() {
31
+ const buf = Buffer.alloc(16);
32
+ const ts = Date.now(); // up to ~52-bit safe integer, well within 48-bit ms range until year 10895
33
+
34
+ // Split ts to avoid bit-shift precision loss above 32 bits.
35
+ // Top 16 bits live in tsHi; bottom 32 bits in tsLo.
36
+ const tsHi = Math.floor(ts / 0x100000000);
37
+ const tsLo = ts >>> 0;
38
+
39
+ buf[0] = (tsHi >>> 8) & 0xff;
40
+ buf[1] = tsHi & 0xff;
41
+ buf[2] = (tsLo >>> 24) & 0xff;
42
+ buf[3] = (tsLo >>> 16) & 0xff;
43
+ buf[4] = (tsLo >>> 8) & 0xff;
44
+ buf[5] = tsLo & 0xff;
45
+
46
+ const r = randomBytes(10);
47
+ buf[6] = 0x70 | (r[0] & 0x0f); // version 7
48
+ buf[7] = r[1];
49
+ buf[8] = 0x80 | (r[2] & 0x3f); // variant 10xxxxxx
50
+ buf[9] = r[3];
51
+ buf[10] = r[4];
52
+ buf[11] = r[5];
53
+ buf[12] = r[6];
54
+ buf[13] = r[7];
55
+ buf[14] = r[8];
56
+ buf[15] = r[9];
57
+
58
+ const hex = buf.toString("hex");
59
+ return (
60
+ hex.slice(0, 8) +
61
+ "-" +
62
+ hex.slice(8, 12) +
63
+ "-" +
64
+ hex.slice(12, 16) +
65
+ "-" +
66
+ hex.slice(16, 20) +
67
+ "-" +
68
+ hex.slice(20, 32)
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Generate a deterministic ID for a given (adapter, originalId) pair.
74
+ * Used when an adapter wants stable IDs across re-ingests of the same row.
75
+ *
76
+ * NOTE: v0 prototype uses a random v7. Adapters that need dedup use
77
+ * (source.adapter, source.originalId) lookup, not id equality.
78
+ * v1 may switch to v5 (name-based) if deterministic IDs become necessary.
79
+ */
80
+ function newIdForSource(_adapter, _originalId) {
81
+ return newId();
82
+ }
83
+
84
+ /**
85
+ * Extract the millisecond timestamp embedded in a UUID v7.
86
+ * Returns null for non-v7 UUIDs.
87
+ */
88
+ function timestampFromId(id) {
89
+ if (typeof id !== "string" || id.length !== 36) return null;
90
+ if (id.charAt(14) !== "7") return null;
91
+ // First 48 bits = chars 0..7 (32 bits) + chars 9..12 (16 bits)
92
+ const hi = parseInt(id.slice(0, 8), 16);
93
+ const lo = parseInt(id.slice(9, 13), 16);
94
+ if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null;
95
+ const ms = hi * 0x10000 + lo;
96
+ return ms;
97
+ }
98
+
99
+ module.exports = {
100
+ newId,
101
+ newIdForSource,
102
+ timestampFromId,
103
+ };
package/lib/index.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @chainlesschain/personal-data-hub
3
+ *
4
+ * UnifiedSchema, validators, SQLCipher LocalVault, AdapterRegistry, and
5
+ * the natural-language AnalysisEngine for the Personal Data Hub middleware.
6
+ *
7
+ * Phase 0 (landed): constants / ids / schemas / batch
8
+ * Phase 1 (landed): vault / migrations / key-providers
9
+ * Phase 2 (landed): adapter-spec / kg-derive / rag-derive / registry / mock-adapter
10
+ * Phase 3 (this): query-parser / prompt-builder / llm-client / analysis
11
+ *
12
+ * See docs/design/Personal_Data_Hub_Architecture.md.
13
+ */
14
+
15
+ "use strict";
16
+
17
+ const constants = require("./constants");
18
+ const ids = require("./ids");
19
+ const schemas = require("./schemas");
20
+ const batch = require("./batch");
21
+ const migrations = require("./migrations");
22
+ const keyProviders = require("./key-providers");
23
+ const { LocalVault } = require("./vault");
24
+ const adapterSpec = require("./adapter-spec");
25
+ const kgDerive = require("./kg-derive");
26
+ const ragDerive = require("./rag-derive");
27
+ const { AdapterRegistry, DEFAULT_BATCH_SIZE } = require("./registry");
28
+ const { MockAdapter } = require("./mock-adapter");
29
+ const queryParser = require("./query-parser");
30
+ const promptBuilder = require("./prompt-builder");
31
+ const { MockLLMClient, OllamaClient } = require("./llm-client");
32
+ const { AnalysisEngine, DEFAULT_MAX_FACTS, DEFAULT_MAX_QUERY_LIMIT } = require("./analysis");
33
+ const bridges = require("./bridges");
34
+ const emailImapAdapter = require("./adapters/email-imap");
35
+
36
+ module.exports = {
37
+ // Constants / enums
38
+ ...constants,
39
+
40
+ // ID generation
41
+ newId: ids.newId,
42
+ newIdForSource: ids.newIdForSource,
43
+ timestampFromId: ids.timestampFromId,
44
+
45
+ // Per-entity validators
46
+ validate: schemas.validate,
47
+ validatePerson: schemas.validatePerson,
48
+ validateEvent: schemas.validateEvent,
49
+ validatePlace: schemas.validatePlace,
50
+ validateItem: schemas.validateItem,
51
+ validateTopic: schemas.validateTopic,
52
+
53
+ // Batch helpers
54
+ emptyBatch: batch.emptyBatch,
55
+ mergeBatches: batch.mergeBatches,
56
+ validateBatch: batch.validateBatch,
57
+ partitionBatch: batch.partitionBatch,
58
+
59
+ // Migrations
60
+ MIGRATIONS: migrations.MIGRATIONS,
61
+ TARGET_SCHEMA_VERSION: migrations.TARGET_VERSION,
62
+ applyMigrations: migrations.applyMigrations,
63
+ getSchemaVersion: migrations.getSchemaVersion,
64
+
65
+ // Key providers
66
+ KEY_HEX_LEN: keyProviders.KEY_HEX_LEN,
67
+ isValidKeyHex: keyProviders.isValidKeyHex,
68
+ generateKeyHex: keyProviders.generateKeyHex,
69
+ InMemoryKeyProvider: keyProviders.InMemoryKeyProvider,
70
+ FileKeyProvider: keyProviders.FileKeyProvider,
71
+
72
+ // Vault
73
+ LocalVault,
74
+
75
+ // Adapter contract
76
+ SENSITIVITY_LEVELS: adapterSpec.SENSITIVITY_LEVELS,
77
+ assertAdapter: adapterSpec.assertAdapter,
78
+
79
+ // KG + RAG derivation
80
+ triple: kgDerive.triple,
81
+ deriveEventTriples: kgDerive.deriveEventTriples,
82
+ derivePersonTriples: kgDerive.derivePersonTriples,
83
+ derivePlaceTriples: kgDerive.derivePlaceTriples,
84
+ deriveItemTriples: kgDerive.deriveItemTriples,
85
+ deriveTopicTriples: kgDerive.deriveTopicTriples,
86
+ deriveBatchTriples: kgDerive.deriveBatchTriples,
87
+ deriveEntityTriples: kgDerive.deriveEntityTriples,
88
+ eventToRagDoc: ragDerive.eventToRagDoc,
89
+ personToRagDoc: ragDerive.personToRagDoc,
90
+ placeToRagDoc: ragDerive.placeToRagDoc,
91
+ itemToRagDoc: ragDerive.itemToRagDoc,
92
+ topicToRagDoc: ragDerive.topicToRagDoc,
93
+ entityToRagDoc: ragDerive.entityToRagDoc,
94
+ deriveBatchDocs: ragDerive.deriveBatchDocs,
95
+
96
+ // Registry + reference adapter
97
+ AdapterRegistry,
98
+ DEFAULT_BATCH_SIZE,
99
+ MockAdapter,
100
+
101
+ // Query parser
102
+ parseQuery: queryParser.parseQuery,
103
+ parseTimeWindow: queryParser.parseTimeWindow,
104
+ parseFilters: queryParser.parseFilters,
105
+ parseIntent: queryParser.parseIntent,
106
+
107
+ // Prompt builder
108
+ DEFAULT_SYSTEM_PROMPT: promptBuilder.DEFAULT_SYSTEM_PROMPT,
109
+ buildPrompt: promptBuilder.buildPrompt,
110
+ summarizeFact: promptBuilder.summarizeFact,
111
+ parseCitations: promptBuilder.parseCitations,
112
+ validateCitations: promptBuilder.validateCitations,
113
+
114
+ // LLM clients (pluggable; production wires CcLLMAdapter)
115
+ MockLLMClient,
116
+ OllamaClient,
117
+
118
+ // Analysis engine
119
+ AnalysisEngine,
120
+ DEFAULT_MAX_FACTS,
121
+ DEFAULT_MAX_QUERY_LIMIT,
122
+
123
+ // Bridges to existing cc infrastructure (LLM / KG / RAG)
124
+ CcLLMAdapter: bridges.CcLLMAdapter,
125
+ CcKgSink: bridges.CcKgSink,
126
+ CcRagSink: bridges.CcRagSink,
127
+ HUB_TO_CC_TYPE: bridges.HUB_TO_CC_TYPE,
128
+ LOCAL_PROVIDERS: bridges.LOCAL_PROVIDERS,
129
+
130
+ // Phase 5.1 — first real production adapter (IMAP email)
131
+ EmailAdapter: emailImapAdapter.EmailAdapter,
132
+ EMAIL_PROVIDERS: emailImapAdapter.EMAIL_PROVIDERS,
133
+ resolveEmailProvider: emailImapAdapter.resolveEmailProvider,
134
+ parseEmailWatermark: emailImapAdapter.parseWatermark,
135
+ formatEmailWatermark: emailImapAdapter.formatWatermark,
136
+ ImapSession: emailImapAdapter.ImapSession,
137
+ ImapAuthFailedError: emailImapAdapter.ImapAuthFailedError,
138
+ ImapConnectionFailedError: emailImapAdapter.ImapConnectionFailedError,
139
+ ImapMailboxNotFoundError: emailImapAdapter.ImapMailboxNotFoundError,
140
+ parseRawEmail: emailImapAdapter.parseRawEmail,
141
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Master-key storage providers.
3
+ *
4
+ * The vault doesn't care WHERE the master key lives — just that something
5
+ * implements get(name) / set(name, key) / del(name) for hex-encoded 32-byte
6
+ * keys. This package ships two providers for dev / testing:
7
+ *
8
+ * - InMemoryKeyProvider — RAM only, lost on process exit (tests)
9
+ * - FileKeyProvider — stores hex blobs under <dir>/<name>.key with
10
+ * 0600 perms (single-user dev box, or fallback
11
+ * when no platform keystore is available)
12
+ *
13
+ * Production integrations live OUTSIDE this package and conform to the
14
+ * KeyProvider interface:
15
+ * - Windows DPAPI (electron main process, win)
16
+ * - macOS Keychain (electron main process, mac)
17
+ * - Linux libsecret (electron main process, linux)
18
+ * - Android Keystore (TEE/StrongBox) (Android app, native bridge)
19
+ * - iOS Keychain (iOS app, native bridge)
20
+ *
21
+ * Plus an optional U-Key / SIMKey second-layer wrap (out of scope here).
22
+ *
23
+ * KeyProvider contract:
24
+ * get(name: string): Promise<string|null> // hex or null if missing
25
+ * set(name: string, hexKey: string): Promise<void>
26
+ * del(name: string): Promise<void>
27
+ * has(name: string): Promise<boolean>
28
+ *
29
+ * Key naming convention recommended:
30
+ * "vault:<vault-id>" master key for a vault
31
+ * "vault:<vault-id>:prev" pre-rotation key, kept for emergency recovery
32
+ * "adapter:<name>:cookie" per-adapter cookie blobs (later phases)
33
+ */
34
+
35
+ "use strict";
36
+
37
+ const fs = require("node:fs");
38
+ const path = require("node:path");
39
+ const crypto = require("node:crypto");
40
+
41
+ const KEY_HEX_LEN = 64; // 32 bytes = 64 hex chars
42
+
43
+ function isValidKeyHex(hex) {
44
+ return typeof hex === "string" && hex.length === KEY_HEX_LEN && /^[0-9a-fA-F]+$/.test(hex);
45
+ }
46
+
47
+ /** Generate a fresh random 32-byte master key, returned hex-encoded. */
48
+ function generateKeyHex() {
49
+ return crypto.randomBytes(32).toString("hex");
50
+ }
51
+
52
+ // ─── InMemoryKeyProvider ─────────────────────────────────────────────────
53
+
54
+ class InMemoryKeyProvider {
55
+ constructor() {
56
+ this._keys = new Map();
57
+ }
58
+ async get(name) {
59
+ return this._keys.has(name) ? this._keys.get(name) : null;
60
+ }
61
+ async set(name, hex) {
62
+ if (!isValidKeyHex(hex)) {
63
+ throw new Error("InMemoryKeyProvider.set: hex must be 64 lowercase/uppercase hex chars");
64
+ }
65
+ this._keys.set(name, hex);
66
+ }
67
+ async del(name) {
68
+ this._keys.delete(name);
69
+ }
70
+ async has(name) {
71
+ return this._keys.has(name);
72
+ }
73
+ }
74
+
75
+ // ─── FileKeyProvider ─────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Stores each key as a separate file at <dir>/<safe-name>.key with 0600
79
+ * permissions. POSIX-only protection; on Windows the perms request becomes
80
+ * a no-op (Windows file ACLs aren't expressible via POSIX mode bits without
81
+ * additional libraries). This is FINE for dev / tests — production setups
82
+ * should plug in DPAPI/Keychain via a custom KeyProvider.
83
+ *
84
+ * Name sanitization: any char outside [A-Za-z0-9_.-] is replaced with "_"
85
+ * to keep the filesystem happy. Colons in canonical names ("vault:abc")
86
+ * become underscores ("vault_abc.key"). Originally-distinct names that
87
+ * collide after sanitization will overwrite each other — callers should
88
+ * avoid collisions or use a different provider.
89
+ */
90
+ class FileKeyProvider {
91
+ constructor(dir) {
92
+ if (typeof dir !== "string" || dir.length === 0) {
93
+ throw new Error("FileKeyProvider: dir must be a non-empty path");
94
+ }
95
+ this.dir = dir;
96
+ fs.mkdirSync(this.dir, { recursive: true });
97
+ }
98
+
99
+ _pathFor(name) {
100
+ const safe = String(name).replace(/[^A-Za-z0-9_.-]/g, "_");
101
+ return path.join(this.dir, safe + ".key");
102
+ }
103
+
104
+ async get(name) {
105
+ const p = this._pathFor(name);
106
+ if (!fs.existsSync(p)) return null;
107
+ const buf = fs.readFileSync(p, "utf8").trim();
108
+ if (!isValidKeyHex(buf)) {
109
+ // Stored file corrupt — fail loudly instead of silently returning null
110
+ // so the user knows their vault key is gone and can act.
111
+ throw new Error(`FileKeyProvider: stored key at ${p} is not valid hex`);
112
+ }
113
+ return buf;
114
+ }
115
+
116
+ async set(name, hex) {
117
+ if (!isValidKeyHex(hex)) {
118
+ throw new Error("FileKeyProvider.set: hex must be 64 hex chars (32 bytes)");
119
+ }
120
+ const p = this._pathFor(name);
121
+ // mode 0600: rw for owner only. On Windows this is ignored by Node.
122
+ fs.writeFileSync(p, hex, { encoding: "utf8", mode: 0o600 });
123
+ try {
124
+ fs.chmodSync(p, 0o600);
125
+ } catch (_err) {
126
+ // Windows — fs.chmod silently ignores most mode bits. Not fatal.
127
+ }
128
+ }
129
+
130
+ async del(name) {
131
+ const p = this._pathFor(name);
132
+ if (fs.existsSync(p)) fs.unlinkSync(p);
133
+ }
134
+
135
+ async has(name) {
136
+ return fs.existsSync(this._pathFor(name));
137
+ }
138
+ }
139
+
140
+ module.exports = {
141
+ KEY_HEX_LEN,
142
+ isValidKeyHex,
143
+ generateKeyHex,
144
+ InMemoryKeyProvider,
145
+ FileKeyProvider,
146
+ };