@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.
- package/README.md +241 -0
- package/__tests__/adapter-spec.test.js +78 -0
- package/__tests__/adapters/email-adapter.test.js +605 -0
- package/__tests__/adapters/email-imap-session.test.js +334 -0
- package/__tests__/adapters/email-parser.test.js +244 -0
- package/__tests__/adapters/email-providers.test.js +84 -0
- package/__tests__/analysis.test.js +302 -0
- package/__tests__/batch.test.js +133 -0
- package/__tests__/bridges-cc-kg.test.js +231 -0
- package/__tests__/bridges-cc-llm.test.js +191 -0
- package/__tests__/bridges-cc-rag.test.js +162 -0
- package/__tests__/ids.test.js +45 -0
- package/__tests__/key-providers.test.js +126 -0
- package/__tests__/kg-derive.test.js +219 -0
- package/__tests__/llm-client.test.js +122 -0
- package/__tests__/mock-adapter.test.js +93 -0
- package/__tests__/prompt-builder.test.js +204 -0
- package/__tests__/query-parser.test.js +150 -0
- package/__tests__/rag-derive.test.js +169 -0
- package/__tests__/registry.test.js +304 -0
- package/__tests__/schemas.test.js +331 -0
- package/__tests__/vault.test.js +506 -0
- package/lib/adapter-spec.js +155 -0
- package/lib/adapters/email-imap/email-adapter.js +398 -0
- package/lib/adapters/email-imap/email-parser.js +177 -0
- package/lib/adapters/email-imap/imap-session.js +294 -0
- package/lib/adapters/email-imap/index.js +26 -0
- package/lib/adapters/email-imap/providers.js +111 -0
- package/lib/analysis.js +226 -0
- package/lib/batch.js +123 -0
- package/lib/bridges/cc-kg-sink.js +264 -0
- package/lib/bridges/cc-llm-adapter.js +169 -0
- package/lib/bridges/cc-rag-sink.js +118 -0
- package/lib/bridges/index.js +44 -0
- package/lib/constants.js +92 -0
- package/lib/ids.js +103 -0
- package/lib/index.js +141 -0
- package/lib/key-providers.js +146 -0
- package/lib/kg-derive.js +214 -0
- package/lib/llm-client.js +171 -0
- package/lib/migrations.js +246 -0
- package/lib/mock-adapter.js +199 -0
- package/lib/prompt-builder.js +205 -0
- package/lib/query-parser.js +250 -0
- package/lib/rag-derive.js +186 -0
- package/lib/registry.js +398 -0
- package/lib/schemas.js +379 -0
- package/lib/vault.js +883 -0
- package/package.json +63 -0
- 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
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -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
|
+
};
|