haechi 1.4.0 → 1.6.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.
@@ -11,8 +11,89 @@ const AUDIT_ID_DOMAIN = "haechi:token-vault:audit-id:v1";
11
11
  // this shape is treated as a misused raw value and never written verbatim.
12
12
  const VAULT_TOKEN_SHAPE = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
13
13
 
14
- export function createLocalTokenVault({
15
- path,
14
+ // A token STORE abstracts the token-record map + the exclusive mutation section
15
+ // so the SAME core-owned tokenization can sit on a whole-file vault today and a
16
+ // shared store (e.g. Redis) in a future satellite — the current whole-file
17
+ // rewrite is not safe with multiple writers, so a shared store needs its own
18
+ // exclusive critical section. The contract is:
19
+ //
20
+ // async mutate(fn) — runs `fn` inside an EXCLUSIVE critical section that
21
+ // serializes concurrent mutations. `fn` receives a MUTABLE view
22
+ // { get(token), set(token, record), delete(token), entries() } over the
23
+ // token-record map, and the store persists the changes ATOMICALLY when `fn`
24
+ // resolves. mutate() returns `fn`'s return value. This is the
25
+ // multi-writer-safety primitive.
26
+ // async read(fn) — read-only access. `fn` receives { get(token), entries() }
27
+ // over a FRESH snapshot (no lock, matching how reveal/detokenize/export read
28
+ // today). read() returns `fn`'s value.
29
+ //
30
+ // The store deliberately knows NOTHING about crypto, reveal governance,
31
+ // retention, or audit — those stay core-owned in createTokenVault so a non-core
32
+ // store can never fork or weaken them. Prune-on-mutation is also core-owned: the
33
+ // core deletes expired entries from the view before each operation, so the file
34
+ // store persists the pruning on the trailing writeVault (no store cooperation
35
+ // needed) and the in-memory store sees the same deletions.
36
+
37
+ // createFileTokenStore implements the store contract over the CURRENT vault
38
+ // mechanism: a `${path}.lock` exclusive section wrapping mkdir + readVault +
39
+ // writeVault, with the view operating on vault.tokens in memory. The on-disk
40
+ // vault JSON format (version/createdAt/tokens, 2-space, trailing newline,
41
+ // temp+rename, 0600) stays byte-identical to the pre-seam vault.
42
+ export function createFileTokenStore({ path }) {
43
+ if (!path) {
44
+ throw new Error("file token store requires path");
45
+ }
46
+
47
+ return {
48
+ async mutate(fn) {
49
+ await mkdir(dirname(path), { recursive: true });
50
+ return withFileLock(`${path}.lock`, async () => {
51
+ const vault = await readVault(path);
52
+ const result = await fn(mutableView(vault.tokens));
53
+ await writeVault(path, vault);
54
+ return result;
55
+ });
56
+ },
57
+
58
+ async read(fn) {
59
+ const vault = await readVault(path);
60
+ return fn(readView(vault.tokens));
61
+ }
62
+ };
63
+ }
64
+
65
+ // A mutable view over a token-record map (the file store backs this with
66
+ // vault.tokens; the in-memory store with a Map). get/set/delete operate on the
67
+ // live map so the store persists whatever the mutation left behind.
68
+ function mutableView(tokens) {
69
+ return {
70
+ get: (token) => tokens[token],
71
+ set: (token, record) => {
72
+ tokens[token] = record;
73
+ },
74
+ delete: (token) => {
75
+ delete tokens[token];
76
+ },
77
+ entries: () => Object.entries(tokens)
78
+ };
79
+ }
80
+
81
+ function readView(tokens) {
82
+ return {
83
+ get: (token) => tokens[token],
84
+ entries: () => Object.entries(tokens)
85
+ };
86
+ }
87
+
88
+ // createTokenVault holds ALL the SECURITY-CRITICAL, core-owned logic:
89
+ // mutationQueue serialization (cross-call), deterministic-vs-random token id
90
+ // derivation, encrypt/decrypt, reveal governance (revealPolicy gate +
91
+ // reasonCodes + safeAuditToken + recordVaultEvent), retention
92
+ // (expiresAt/prune-on-mutation), detokenize, purge/purgeExpired,
93
+ // exportMetadata, and capabilities. The store only supplies the exclusive
94
+ // mutate/read primitive over the token-record map.
95
+ export function createTokenVault({
96
+ store,
16
97
  cryptoProvider,
17
98
  revealPolicy = "disabled",
18
99
  retentionDays = 30,
@@ -20,8 +101,8 @@ export function createLocalTokenVault({
20
101
  deterministic = false,
21
102
  deterministicTypes = null
22
103
  }) {
23
- if (!path) {
24
- throw new Error("Local token vault requires path");
104
+ if (!store || typeof store.mutate !== "function" || typeof store.read !== "function") {
105
+ throw new Error("token vault requires a store with mutate(fn) and read(fn) methods");
25
106
  }
26
107
  if (!cryptoProvider) {
27
108
  throw new Error("Local token vault requires cryptoProvider");
@@ -37,16 +118,31 @@ export function createLocalTokenVault({
37
118
  return !deterministicTypes || deterministicTypes.includes(type);
38
119
  }
39
120
 
121
+ // The mutationQueue (cross-call serialization) stays in core, wrapping
122
+ // store.mutate. Together with the store's own exclusive critical section this
123
+ // keeps concurrent tokenize/purge from corrupting or losing tokens.
40
124
  let mutationQueue = Promise.resolve();
41
125
  async function enqueueMutation(operation) {
42
- const mutation = mutationQueue.then(async () => {
43
- await mkdir(dirname(path), { recursive: true });
44
- return withFileLock(`${path}.lock`, operation);
45
- });
126
+ const mutation = mutationQueue.then(() => store.mutate(operation));
46
127
  mutationQueue = mutation.catch(() => {});
47
128
  return mutation;
48
129
  }
49
130
 
131
+ // Prune expired entries from the mutable view before each operation. For the
132
+ // file store this deletes from the in-memory map so they are gone after the
133
+ // trailing writeVault; for any store the deletions are persisted by mutate().
134
+ // Returns the number pruned (purgeExpired counts on this).
135
+ function pruneExpiredView(view, now = Date.now()) {
136
+ let purged = 0;
137
+ for (const [token, record] of view.entries()) {
138
+ if (record.expiresAt && Date.parse(record.expiresAt) < now) {
139
+ view.delete(token);
140
+ purged += 1;
141
+ }
142
+ }
143
+ return purged;
144
+ }
145
+
50
146
  // The audit `token` field must never carry a raw secret. A legitimate token
51
147
  // id is a non-sensitive opaque `tok_<type>_<hexhash>` — recorded verbatim for
52
148
  // correlation. A caller who misuses the API and passes a raw value where a
@@ -122,14 +218,13 @@ export function createLocalTokenVault({
122
218
  })).slice(0, 32)}`
123
219
  : `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
124
220
 
125
- return enqueueMutation(async () => {
126
- const vault = await readVault(path);
127
- pruneExpiredTokens(vault);
221
+ return enqueueMutation(async (view) => {
222
+ pruneExpiredView(view);
128
223
 
129
- const existing = vault.tokens[token];
224
+ const existing = view.get(token);
130
225
  if (existing) {
131
226
  existing.expiresAt = addDays(new Date(), retentionDays).toISOString();
132
- await writeVault(path, vault);
227
+ view.set(token, existing);
133
228
  return { token, type, reused: true };
134
229
  }
135
230
 
@@ -140,15 +235,14 @@ export function createLocalTokenVault({
140
235
  type,
141
236
  context
142
237
  };
143
- vault.tokens[token] = {
238
+ view.set(token, {
144
239
  type,
145
240
  createdAt: createdAt.toISOString(),
146
241
  expiresAt: addDays(createdAt, retentionDays).toISOString(),
147
242
  metadata: sanitizeMetadata(metadata),
148
243
  envelope: await cryptoProvider.encrypt({ plaintext, aad }),
149
244
  aad
150
- };
151
- await writeVault(path, vault);
245
+ });
152
246
  return { token, type };
153
247
  });
154
248
  },
@@ -166,8 +260,7 @@ export function createLocalTokenVault({
166
260
  // token); the message itself never interpolates the token argument.
167
261
  let reasonCode = "reveal_error";
168
262
  try {
169
- const vault = await readVault(path);
170
- const record = vault.tokens[token];
263
+ const record = await store.read((view) => view.get(token));
171
264
  if (!record) {
172
265
  reasonCode = "unknown_token";
173
266
  throw new Error("Unknown token");
@@ -210,12 +303,19 @@ export function createLocalTokenVault({
210
303
  // reachable through the proxy's explicit detokenizeResponses opt-in and is
211
304
  // limited to the caller-supplied token set. Audited by count, no plaintext.
212
305
  async detokenize({ tokens }) {
213
- const vault = await readVault(path);
306
+ const records = await store.read((view) => {
307
+ const found = new Map();
308
+ for (const token of tokens) {
309
+ found.set(token, view.get(token));
310
+ }
311
+ return found;
312
+ });
313
+
214
314
  const values = new Map();
215
315
  let skipped = 0;
216
316
 
217
317
  for (const token of tokens) {
218
- const record = vault.tokens[token];
318
+ const record = records.get(token);
219
319
  if (!record || (record.expiresAt && Date.parse(record.expiresAt) < Date.now())) {
220
320
  skipped += 1;
221
321
  continue;
@@ -237,36 +337,30 @@ export function createLocalTokenVault({
237
337
  return values;
238
338
  },
239
339
  async purge({ token }) {
240
- return enqueueMutation(async () => {
241
- const vault = await readVault(path);
242
- pruneExpiredTokens(vault);
243
- const existed = Boolean(vault.tokens[token]);
244
- delete vault.tokens[token];
245
- await writeVault(path, vault);
246
- await recordVaultEvent({
247
- operation: "token-vault:purge",
248
- decision: "purge",
249
- token
250
- });
251
- return { token, purged: existed, purgedAt: new Date().toISOString() };
340
+ const existed = await enqueueMutation(async (view) => {
341
+ pruneExpiredView(view);
342
+ const present = Boolean(view.get(token));
343
+ view.delete(token);
344
+ return present;
345
+ });
346
+ await recordVaultEvent({
347
+ operation: "token-vault:purge",
348
+ decision: "purge",
349
+ token
252
350
  });
351
+ return { token, purged: existed, purgedAt: new Date().toISOString() };
253
352
  },
254
353
  async purgeExpired() {
255
- return enqueueMutation(async () => {
256
- const vault = await readVault(path);
257
- const purged = pruneExpiredTokens(vault);
258
- await writeVault(path, vault);
259
- await recordVaultEvent({
260
- operation: "token-vault:purge-expired",
261
- decision: "purge_expired",
262
- count: purged
263
- });
264
- return { purged, purgedAt: new Date().toISOString() };
354
+ const purged = await enqueueMutation(async (view) => pruneExpiredView(view));
355
+ await recordVaultEvent({
356
+ operation: "token-vault:purge-expired",
357
+ decision: "purge_expired",
358
+ count: purged
265
359
  });
360
+ return { purged, purgedAt: new Date().toISOString() };
266
361
  },
267
362
  async exportMetadata({ type = null } = {}) {
268
- const vault = await readVault(path);
269
- return Object.entries(vault.tokens)
363
+ return store.read((view) => view.entries()
270
364
  .filter(([, record]) => !type || record.type === type)
271
365
  .map(([token, record]) => ({
272
366
  token,
@@ -274,11 +368,39 @@ export function createLocalTokenVault({
274
368
  createdAt: record.createdAt,
275
369
  expiresAt: record.expiresAt,
276
370
  metadata: sanitizeMetadata(record.metadata ?? {})
277
- }));
371
+ })));
278
372
  }
279
373
  };
280
374
  }
281
375
 
376
+ // Thin back-compat wrapper: the original file-backed vault is now
377
+ // createTokenVault over createFileTokenStore. Its returned shape (id, version,
378
+ // capabilities, tokenize, reveal, detokenize, purge, purgeExpired,
379
+ // exportMetadata) and on-disk bytes are unchanged, so existing call sites
380
+ // (runtime.mjs injection, tests) keep working untouched.
381
+ export function createLocalTokenVault({
382
+ path,
383
+ cryptoProvider,
384
+ revealPolicy = "disabled",
385
+ retentionDays = 30,
386
+ auditSink = null,
387
+ deterministic = false,
388
+ deterministicTypes = null
389
+ }) {
390
+ if (!path) {
391
+ throw new Error("Local token vault requires path");
392
+ }
393
+ return createTokenVault({
394
+ store: createFileTokenStore({ path }),
395
+ cryptoProvider,
396
+ revealPolicy,
397
+ retentionDays,
398
+ auditSink,
399
+ deterministic,
400
+ deterministicTypes
401
+ });
402
+ }
403
+
282
404
  export async function readVault(path) {
283
405
  try {
284
406
  return JSON.parse(await readFile(path, "utf8"));
@@ -301,17 +423,6 @@ async function writeVault(path, vault) {
301
423
  await rename(tempPath, path);
302
424
  }
303
425
 
304
- function pruneExpiredTokens(vault, now = Date.now()) {
305
- let purged = 0;
306
- for (const [token, record] of Object.entries(vault.tokens)) {
307
- if (record.expiresAt && Date.parse(record.expiresAt) < now) {
308
- delete vault.tokens[token];
309
- purged += 1;
310
- }
311
- }
312
- return purged;
313
- }
314
-
315
426
  function sanitizeMetadata(metadata) {
316
427
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !["value", "plaintext", "payload"].includes(key)));
317
428
  }