usertrust 1.5.0 → 2.0.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 (115) hide show
  1. package/README.md +17 -1
  2. package/dist/anomaly/detector.js.map +1 -1
  3. package/dist/anomaly/index.d.ts +2 -2
  4. package/dist/anomaly/index.d.ts.map +1 -1
  5. package/dist/anomaly/index.js +1 -1
  6. package/dist/anomaly/index.js.map +1 -1
  7. package/dist/audit/anchor-doctor.d.ts +42 -0
  8. package/dist/audit/anchor-doctor.d.ts.map +1 -0
  9. package/dist/audit/anchor-doctor.js +285 -0
  10. package/dist/audit/anchor-doctor.js.map +1 -0
  11. package/dist/audit/anchor-verify.d.ts +229 -0
  12. package/dist/audit/anchor-verify.d.ts.map +1 -0
  13. package/dist/audit/anchor-verify.js +809 -0
  14. package/dist/audit/anchor-verify.js.map +1 -0
  15. package/dist/audit/anchor.d.ts +177 -0
  16. package/dist/audit/anchor.d.ts.map +1 -0
  17. package/dist/audit/anchor.js +922 -0
  18. package/dist/audit/anchor.js.map +1 -0
  19. package/dist/audit/canonical.js +1 -1
  20. package/dist/audit/canonical.js.map +1 -1
  21. package/dist/audit/chain.d.ts.map +1 -1
  22. package/dist/audit/chain.js +21 -7
  23. package/dist/audit/chain.js.map +1 -1
  24. package/dist/audit/read.d.ts +19 -0
  25. package/dist/audit/read.d.ts.map +1 -0
  26. package/dist/audit/read.js +94 -0
  27. package/dist/audit/read.js.map +1 -0
  28. package/dist/audit/rekor-verify.d.ts +116 -0
  29. package/dist/audit/rekor-verify.d.ts.map +1 -0
  30. package/dist/audit/rekor-verify.js +533 -0
  31. package/dist/audit/rekor-verify.js.map +1 -0
  32. package/dist/audit/rekor.d.ts +71 -0
  33. package/dist/audit/rekor.d.ts.map +1 -0
  34. package/dist/audit/rekor.js +556 -0
  35. package/dist/audit/rekor.js.map +1 -0
  36. package/dist/audit/rotation.js +1 -1
  37. package/dist/audit/rotation.js.map +1 -1
  38. package/dist/audit/sigv4.d.ts +44 -0
  39. package/dist/audit/sigv4.d.ts.map +1 -0
  40. package/dist/audit/sigv4.js +134 -0
  41. package/dist/audit/sigv4.js.map +1 -0
  42. package/dist/audit/verify.d.ts +78 -0
  43. package/dist/audit/verify.d.ts.map +1 -1
  44. package/dist/audit/verify.js +195 -1
  45. package/dist/audit/verify.js.map +1 -1
  46. package/dist/cli/anchor.d.ts +3 -0
  47. package/dist/cli/anchor.d.ts.map +1 -0
  48. package/dist/cli/anchor.js +626 -0
  49. package/dist/cli/anchor.js.map +1 -0
  50. package/dist/cli/completions.js +1 -1
  51. package/dist/cli/export.d.ts +3 -0
  52. package/dist/cli/export.d.ts.map +1 -0
  53. package/dist/cli/export.js +58 -0
  54. package/dist/cli/export.js.map +1 -0
  55. package/dist/cli/health.js.map +1 -1
  56. package/dist/cli/init.js +1 -1
  57. package/dist/cli/init.js.map +1 -1
  58. package/dist/cli/inspect.d.ts.map +1 -1
  59. package/dist/cli/inspect.js +4 -34
  60. package/dist/cli/inspect.js.map +1 -1
  61. package/dist/cli/main.d.ts +2 -2
  62. package/dist/cli/main.d.ts.map +1 -1
  63. package/dist/cli/main.js +20 -1
  64. package/dist/cli/main.js.map +1 -1
  65. package/dist/cli/pricing.js +1 -1
  66. package/dist/cli/pricing.js.map +1 -1
  67. package/dist/cli/ui.d.ts +3 -0
  68. package/dist/cli/ui.d.ts.map +1 -0
  69. package/dist/cli/ui.js +35 -0
  70. package/dist/cli/ui.js.map +1 -0
  71. package/dist/cli/verify.d.ts.map +1 -1
  72. package/dist/cli/verify.js +342 -3
  73. package/dist/cli/verify.js.map +1 -1
  74. package/dist/config.d.ts.map +1 -1
  75. package/dist/config.js.map +1 -1
  76. package/dist/detect.d.ts +37 -15
  77. package/dist/detect.d.ts.map +1 -1
  78. package/dist/detect.js +17 -6
  79. package/dist/detect.js.map +1 -1
  80. package/dist/export/markdown.d.ts +12 -0
  81. package/dist/export/markdown.d.ts.map +1 -0
  82. package/dist/export/markdown.js +142 -0
  83. package/dist/export/markdown.js.map +1 -0
  84. package/dist/govern.d.ts +60 -3
  85. package/dist/govern.d.ts.map +1 -1
  86. package/dist/govern.js +842 -229
  87. package/dist/govern.js.map +1 -1
  88. package/dist/headless.d.ts.map +1 -1
  89. package/dist/headless.js +4 -4
  90. package/dist/headless.js.map +1 -1
  91. package/dist/index.d.ts +33 -26
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +30 -25
  94. package/dist/index.js.map +1 -1
  95. package/dist/ledger/client.d.ts.map +1 -1
  96. package/dist/ledger/client.js +57 -46
  97. package/dist/ledger/client.js.map +1 -1
  98. package/dist/ledger/engine.d.ts.map +1 -1
  99. package/dist/ledger/engine.js +22 -13
  100. package/dist/ledger/engine.js.map +1 -1
  101. package/dist/shared/types.d.ts +114 -475
  102. package/dist/shared/types.d.ts.map +1 -1
  103. package/dist/shared/types.js +13 -13
  104. package/dist/shared/types.js.map +1 -1
  105. package/dist/snapshot/checkpoint.d.ts.map +1 -1
  106. package/dist/snapshot/checkpoint.js +20 -2
  107. package/dist/snapshot/checkpoint.js.map +1 -1
  108. package/dist/streaming.d.ts +9 -4
  109. package/dist/streaming.d.ts.map +1 -1
  110. package/dist/streaming.js +27 -0
  111. package/dist/streaming.js.map +1 -1
  112. package/dist/supply-chain/sign.d.ts.map +1 -1
  113. package/dist/supply-chain/sign.js +3 -0
  114. package/dist/supply-chain/sign.js.map +1 -1
  115. package/package.json +6 -4
@@ -0,0 +1,922 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Usertools, Inc.
3
+ /**
4
+ * External Anchor Emitter — signed chain-head checkpoints
5
+ *
6
+ * Periodically commits the audit chain's head (Merkle root + size + last
7
+ * hash), Ed25519-signed and hash-chained to the previous checkpoint, to an
8
+ * append-only store the vault operator cannot silently rewrite. Spec:
9
+ * docs/superpowers/specs/2026-07-26-external-anchoring-design.md §5.
10
+ *
11
+ * Additive by construction: the append path (events.jsonl / .meta bytes) is
12
+ * untouched; the emitter only OBSERVES the writer's outputs. Emission is
13
+ * serialized by its own advisory lock (`audit/anchors/.anchor-writer.lock`)
14
+ * so an in-process scheduler and a cron-driven `usertrust anchor now` cannot
15
+ * race the mirror and mint fork evidence. `appendEvent` never blocks on any
16
+ * of this.
17
+ *
18
+ * The vault-local mirror (`audit/anchors/anchors.jsonl`) is a CACHE — trust
19
+ * comes from the external store copies. The private key NEVER lives inside
20
+ * the vault.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import { createPrivateKey, createPublicKey, sign as cryptoSign, generateKeyPairSync, randomUUID, } from "node:crypto";
24
+ import { closeSync, existsSync, constants as fsConstants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { join, resolve, sep } from "node:path";
27
+ import { GENESIS_HASH, VAULT_DIR } from "../shared/constants.js";
28
+ import { anchorPayloadHash, anchorSigningPreimage, gatherOrderedEventHashes, keyIdFromKeyObject, parseAnchorRecord, parseAnchorsContent, publicKeyFromSpkiBase64, verifySignatureRaw, } from "./anchor-verify.js";
29
+ import { canonicalize } from "./canonical.js";
30
+ import { buildMerkleTree } from "./merkle.js";
31
+ import { DEFAULT_REKOR_URL, rekorSink, s3Sink } from "./rekor.js";
32
+ export const DEFAULT_ANCHOR_EVERY_EVENTS = 1000;
33
+ export const DEFAULT_ANCHOR_EVERY_MS = 60_000;
34
+ const DEFAULT_KEY_ENV = "USERTRUST_ANCHOR_KEY";
35
+ const SNAPSHOT_RETRIES = 3;
36
+ /**
37
+ * Atomic identity.json write: temp file + fsync + rename. A bare in-place
38
+ * writeFileSync could be truncated by a crash mid-write, wedging the vault
39
+ * between "no anchor identity" (emitter) and "identity already exists" (init).
40
+ */
41
+ function writeIdentityFile(rootDir, identity) {
42
+ const path = join(anchorsDir(rootDir), "identity.json");
43
+ const tmp = `${path}.tmp-${process.pid}`;
44
+ const fd = openSync(tmp, "w", 0o600);
45
+ try {
46
+ writeSync(fd, JSON.stringify(identity, null, "\t"));
47
+ fsyncSync(fd);
48
+ }
49
+ finally {
50
+ closeSync(fd);
51
+ }
52
+ renameSync(tmp, path);
53
+ }
54
+ /** Persist the durable anchorSeq high-water (never decreases). */
55
+ function bumpAnchorHighWater(rootDir, anchorSeq) {
56
+ const identity = readAnchorIdentity(rootDir);
57
+ if (identity === null)
58
+ return;
59
+ if ((identity.lastAnchorSeq ?? 0) >= anchorSeq)
60
+ return;
61
+ writeIdentityFile(rootDir, { ...identity, lastAnchorSeq: anchorSeq });
62
+ }
63
+ export function anchorsDir(rootDir) {
64
+ return join(rootDir, VAULT_DIR, "audit", "anchors");
65
+ }
66
+ export function readAnchorIdentity(rootDir) {
67
+ const p = join(anchorsDir(rootDir), "identity.json");
68
+ if (!existsSync(p))
69
+ return null;
70
+ try {
71
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
72
+ if (parsed.v === 1 && typeof parsed.vaultId === "string" && typeof parsed.keyId === "string") {
73
+ return parsed;
74
+ }
75
+ return null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ export function defaultKeyPath(vaultId) {
82
+ return join(homedir(), ".usertrust", "keys", `${vaultId}.anchor.pem`);
83
+ }
84
+ /**
85
+ * Resolve a path to its canonical absolute form, realpath-ing the deepest
86
+ * EXISTING ancestor so symlinked parents (macOS /var → /private/var) compare
87
+ * equal even when the leaf doesn't exist yet.
88
+ */
89
+ function realResolve(p) {
90
+ const abs = resolve(p);
91
+ let base = abs;
92
+ const suffix = [];
93
+ while (!existsSync(base)) {
94
+ const parent = join(base, "..");
95
+ if (parent === base)
96
+ break;
97
+ suffix.unshift(base.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
98
+ base = parent;
99
+ }
100
+ try {
101
+ base = realpathSync(base);
102
+ }
103
+ catch {
104
+ /* keep the resolved form */
105
+ }
106
+ return suffix.length === 0 ? base : join(base, ...suffix);
107
+ }
108
+ /**
109
+ * Mint the vault's anchor identity: Ed25519 keypair (private key OUTSIDE the
110
+ * vault, mode 0600), `identity.json`, and an empty mirror file. Returns the
111
+ * public PEM + keyId for out-of-band pinning. Idempotent: refuses to
112
+ * overwrite an existing identity.
113
+ */
114
+ export function initAnchorIdentity(rootDir, opts) {
115
+ const dir = anchorsDir(rootDir);
116
+ const identityPath = join(dir, "identity.json");
117
+ if (existsSync(identityPath)) {
118
+ throw new Error(`Anchor identity already exists: ${identityPath}`);
119
+ }
120
+ const vaultId = randomUUID();
121
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
122
+ const keyId = keyIdFromKeyObject(publicKey);
123
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
124
+ const publicKeySpki = publicKey.export({ type: "spki", format: "der" }).toString("base64");
125
+ // Canonicalize BOTH sides before comparing: a relative --key-file (e.g.
126
+ // ".usertrust/keys/anchor.pem") would never match an absolute prefix, and
127
+ // symlinked temp dirs (macOS /var → /private/var) would defeat a plain
128
+ // resolve() comparison — either way silently landing the signing key
129
+ // inside the vault it vouches for (AC-6.2).
130
+ const keyFile = realResolve(opts?.keyFile ?? defaultKeyPath(vaultId));
131
+ const keyDir = join(keyFile, "..");
132
+ const vaultAbs = realResolve(join(rootDir, VAULT_DIR));
133
+ if (keyFile === vaultAbs || keyFile.startsWith(vaultAbs + sep)) {
134
+ throw new Error("Refusing to write the anchor private key inside the vault (AC-6.2)");
135
+ }
136
+ mkdirSync(keyDir, { recursive: true, mode: 0o700 });
137
+ writeFileSync(keyFile, privateKey.export({ type: "pkcs8", format: "pem" }), {
138
+ mode: 0o600,
139
+ });
140
+ mkdirSync(join(dir, "outbox"), { recursive: true });
141
+ const identity = {
142
+ v: 1,
143
+ vaultId,
144
+ keyId,
145
+ publicKeySpki,
146
+ createdAt: new Date().toISOString(),
147
+ keyHistory: [{ keyId, publicKeySpki }],
148
+ };
149
+ writeIdentityFile(rootDir, identity);
150
+ // Touch the mirror so "mirror file missing" (accidental loss / partial
151
+ // restore) is distinguishable from "fresh vault, no anchors yet".
152
+ const mirrorPath = join(dir, "anchors.jsonl");
153
+ if (!existsSync(mirrorPath)) {
154
+ writeFileSync(mirrorPath, "", { mode: 0o600 });
155
+ }
156
+ return { identity, publicKeyPem, keyFile };
157
+ }
158
+ export function resolveSigner(config, vaultId) {
159
+ if (config.type === "external") {
160
+ const pub = createPublicKey({
161
+ key: Buffer.from(config.publicKeySpki, "base64"),
162
+ format: "der",
163
+ type: "spki",
164
+ });
165
+ const derivedKeyId = keyIdFromKeyObject(pub);
166
+ if (derivedKeyId !== config.keyId) {
167
+ throw new Error("external signer keyId does not match its publicKeySpki");
168
+ }
169
+ return {
170
+ keyId: config.keyId,
171
+ sign: async (preimage) => Buffer.from(await config.sign(Buffer.from(preimage, "utf8"))).toString("base64"),
172
+ };
173
+ }
174
+ let pem;
175
+ const envName = config.env ?? DEFAULT_KEY_ENV;
176
+ const envValue = process.env[envName];
177
+ if (envValue !== undefined && envValue !== "") {
178
+ pem = envValue.includes("-----BEGIN") ? envValue : readFileSync(envValue, "utf-8");
179
+ }
180
+ else if (config.file !== undefined) {
181
+ pem = readFileSync(config.file, "utf-8");
182
+ }
183
+ else if (vaultId !== undefined && existsSync(defaultKeyPath(vaultId))) {
184
+ pem = readFileSync(defaultKeyPath(vaultId), "utf-8");
185
+ }
186
+ if (pem === undefined) {
187
+ throw new Error(`No anchor private key: set ${envName}, pass signer.file, or run \`usertrust anchor init\``);
188
+ }
189
+ const privateKey = createPrivateKey(pem);
190
+ // @types/node 26 dropped KeyObject from createPublicKey's input union; Node
191
+ // derives the public key from a private KeyObject at runtime (documented) —
192
+ // types-only assertion, runtime call unchanged.
193
+ const publicKey = createPublicKey(privateKey);
194
+ return {
195
+ keyId: keyIdFromKeyObject(publicKey),
196
+ sign: async (preimage) => cryptoSign(null, Buffer.from(preimage, "utf8"), privateKey).toString("base64"),
197
+ };
198
+ }
199
+ // ── Sinks ──
200
+ function fileSink(path) {
201
+ return {
202
+ name: `file:${path}`,
203
+ publish: async (record) => {
204
+ const fd = openSync(path, "a", 0o600);
205
+ try {
206
+ writeSync(fd, `${canonicalize(record)}\n`);
207
+ fsyncSync(fd);
208
+ }
209
+ finally {
210
+ closeSync(fd);
211
+ }
212
+ },
213
+ };
214
+ }
215
+ function httpsSink(url, headers) {
216
+ return {
217
+ name: `https:${url}`,
218
+ publish: (record) => new Promise((resolve, reject) => {
219
+ import("node:https")
220
+ .then((https) => {
221
+ const body = Buffer.from(`${canonicalize(record)}\n`, "utf8");
222
+ const req = https.request(url, {
223
+ method: "POST",
224
+ headers: {
225
+ "content-type": "application/json",
226
+ "content-length": String(body.length),
227
+ ...headers,
228
+ },
229
+ timeout: 15_000,
230
+ }, (res) => {
231
+ res.resume();
232
+ const code = res.statusCode ?? 0;
233
+ if (code >= 200 && code < 300) {
234
+ resolve();
235
+ }
236
+ else {
237
+ reject(new Error(`anchor sink HTTP ${code}`));
238
+ }
239
+ });
240
+ req.on("timeout", () => req.destroy(new Error("anchor sink timeout")));
241
+ req.on("error", reject);
242
+ req.end(body);
243
+ })
244
+ .catch(reject);
245
+ }),
246
+ };
247
+ }
248
+ function commandSink(argv) {
249
+ const [cmd, ...args] = argv;
250
+ return {
251
+ name: `command:${cmd ?? ""}`,
252
+ publish: (record) => new Promise((resolve, reject) => {
253
+ if (cmd === undefined) {
254
+ reject(new Error("command sink: empty argv"));
255
+ return;
256
+ }
257
+ const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
258
+ let stderr = "";
259
+ child.stderr.on("data", (d) => {
260
+ stderr += d.toString();
261
+ });
262
+ child.on("error", reject);
263
+ child.on("close", (code) => {
264
+ if (code === 0) {
265
+ resolve();
266
+ }
267
+ else {
268
+ reject(new Error(`command sink exited ${code}: ${stderr.slice(0, 300)}`));
269
+ }
270
+ });
271
+ // A command that exits without reading stdin (crash, refusal)
272
+ // races the write and emits EPIPE on the stdin stream — as an
273
+ // UNHANDLED error unless listened for. The exit code above is
274
+ // the verdict; the broken pipe is just its symptom.
275
+ child.stdin.on("error", () => {
276
+ /* verdict comes from the close handler's exit code */
277
+ });
278
+ child.stdin.end(`${canonicalize(record)}\n`);
279
+ }),
280
+ };
281
+ }
282
+ /**
283
+ * `rootDir` is optional so existing callers keep working: only the Rekor sink
284
+ * needs it, because inclusion receipts are persisted into the vault alongside
285
+ * the mirror.
286
+ */
287
+ export function createSink(config, rootDir) {
288
+ // Already an AnchorSink instance (custom/test sink) — pass through.
289
+ if ("publish" in config && typeof config.publish === "function") {
290
+ return config;
291
+ }
292
+ const cfg = config;
293
+ switch (cfg.type) {
294
+ case "file":
295
+ return fileSink(cfg.path);
296
+ case "https":
297
+ return httpsSink(cfg.url, cfg.headers);
298
+ case "command":
299
+ return commandSink(cfg.argv);
300
+ case "s3":
301
+ return s3Sink({
302
+ bucket: cfg.bucket,
303
+ region: cfg.region,
304
+ ...(cfg.prefix !== undefined ? { prefix: cfg.prefix } : {}),
305
+ ...(cfg.endpoint !== undefined ? { endpoint: cfg.endpoint } : {}),
306
+ });
307
+ case "rekor":
308
+ if (rootDir === undefined) {
309
+ throw new Error("rekor sink requires rootDir");
310
+ }
311
+ return rekorSink(rootDir, cfg.url ?? DEFAULT_REKOR_URL);
312
+ }
313
+ }
314
+ // ── Emitter advisory lock (anchor path only — never touches .audit-writer.lock) ──
315
+ /**
316
+ * Lock paths currently held by a LIVE emitter in THIS process. A same-PID
317
+ * lock file is only reclaimable when no live emitter here owns it (i.e. it
318
+ * is a leftover from a crashed prior instance) — otherwise a sibling emitter
319
+ * in the same process would steal a live lock and race the mirror (the
320
+ * exact fork-evidence scenario the lock exists to prevent).
321
+ */
322
+ const inProcessAnchorLocks = new Set();
323
+ function tryAcquireAnchorLock(dir) {
324
+ const lockPath = join(dir, ".anchor-writer.lock");
325
+ if (inProcessAnchorLocks.has(lockPath))
326
+ return null;
327
+ const content = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
328
+ const attempt = () => {
329
+ try {
330
+ const fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
331
+ try {
332
+ writeSync(fd, content);
333
+ fsyncSync(fd);
334
+ }
335
+ finally {
336
+ closeSync(fd);
337
+ }
338
+ inProcessAnchorLocks.add(lockPath);
339
+ return () => {
340
+ inProcessAnchorLocks.delete(lockPath);
341
+ try {
342
+ unlinkSync(lockPath);
343
+ }
344
+ catch {
345
+ /* already removed */
346
+ }
347
+ };
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ };
353
+ const first = attempt();
354
+ if (first !== null)
355
+ return first;
356
+ // Stale-lock detection: same liveness check as the audit writer lock.
357
+ try {
358
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
359
+ if (typeof existing.pid === "number" && existing.pid !== process.pid) {
360
+ try {
361
+ process.kill(existing.pid, 0);
362
+ return null; // live holder
363
+ }
364
+ catch (err) {
365
+ if (!(err instanceof Error && "code" in err && err.code === "ESRCH")) {
366
+ return null;
367
+ }
368
+ }
369
+ }
370
+ else if (typeof existing.pid === "number" && existing.pid === process.pid) {
371
+ // Same-PID lock file with no live in-process owner (checked above):
372
+ // leftover from a crashed prior emitter — safe to reclaim.
373
+ }
374
+ else {
375
+ return null;
376
+ }
377
+ }
378
+ catch {
379
+ // Corrupt lock file — also reclaimed, but only via the atomic rename
380
+ // below so a concurrent reclaimer cannot double-acquire.
381
+ }
382
+ // ATOMIC reclaim: exactly one contender wins the rename; the loser's
383
+ // rename throws (ENOENT) and backs off. A bare check-then-unlink would let
384
+ // a slow contender delete the winner's freshly created lock (TOCTOU) and
385
+ // admit two emitters — the exact race the lock exists to prevent.
386
+ try {
387
+ const reclaimPath = `${lockPath}.reclaim-${process.pid}-${Date.now()}`;
388
+ renameSync(lockPath, reclaimPath);
389
+ unlinkSync(reclaimPath);
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ return attempt();
395
+ }
396
+ // ── Mirror / outbox helpers ──
397
+ function readMirrorRecords(dir) {
398
+ const mirrorPath = join(dir, "anchors.jsonl");
399
+ if (!existsSync(mirrorPath))
400
+ return { present: false, records: [], errors: [] };
401
+ try {
402
+ const { records, errors } = parseAnchorsContent(readFileSync(mirrorPath, "utf-8"));
403
+ return { present: true, records: records.sort((a, b) => a.anchorSeq - b.anchorSeq), errors };
404
+ }
405
+ catch (err) {
406
+ return {
407
+ present: true,
408
+ records: [],
409
+ errors: [`mirror unreadable: ${err instanceof Error ? err.message : String(err)}`],
410
+ };
411
+ }
412
+ }
413
+ /** Read pending outbox record CONTENTS in anchorSeq order (for redelivery). */
414
+ function pendingOutboxRecords(dir) {
415
+ return outboxSeqs(dir)
416
+ .sort((a, b) => a - b)
417
+ .flatMap((seq) => {
418
+ try {
419
+ const { record } = parseAnchorRecord(readFileSync(outboxPath(dir, seq), "utf-8").trim());
420
+ return record !== null && record.anchorSeq === seq ? [record] : [];
421
+ }
422
+ catch {
423
+ return [];
424
+ }
425
+ });
426
+ }
427
+ function outboxSeqs(dir) {
428
+ const outDir = join(dir, "outbox");
429
+ if (!existsSync(outDir))
430
+ return [];
431
+ try {
432
+ return readdirSync(outDir)
433
+ .filter((f) => f.endsWith(".json"))
434
+ .map((f) => Number.parseInt(f.slice(0, -5), 10))
435
+ .filter((n) => Number.isSafeInteger(n) && n >= 1);
436
+ }
437
+ catch {
438
+ return [];
439
+ }
440
+ }
441
+ function outboxPath(dir, anchorSeq) {
442
+ return join(dir, "outbox", `${String(anchorSeq).padStart(12, "0")}.json`);
443
+ }
444
+ /** Fsync'd outbox write — the record's durable delivery intent. */
445
+ function writeOutboxEntry(dir, record) {
446
+ mkdirSync(join(dir, "outbox"), { recursive: true });
447
+ const fd = openSync(outboxPath(dir, record.anchorSeq), "w", 0o600);
448
+ try {
449
+ writeSync(fd, canonicalize(record));
450
+ fsyncSync(fd);
451
+ }
452
+ finally {
453
+ closeSync(fd);
454
+ }
455
+ }
456
+ function readMeta(rootDir) {
457
+ const metaPath = join(rootDir, VAULT_DIR, "audit", "events.jsonl.meta");
458
+ if (!existsSync(metaPath))
459
+ return "absent";
460
+ try {
461
+ const raw = readFileSync(metaPath, "utf-8");
462
+ if (raw.trim() === "")
463
+ return "unreadable";
464
+ const parsed = JSON.parse(raw);
465
+ if (typeof parsed.lastHash === "string" && typeof parsed.sequence === "number") {
466
+ return { lastHash: parsed.lastHash, sequence: parsed.sequence };
467
+ }
468
+ return "unreadable";
469
+ }
470
+ catch {
471
+ return "unreadable";
472
+ }
473
+ }
474
+ // ── Emitter ──
475
+ export function createAnchorEmitter(rootDir, config) {
476
+ const vaultPath = join(rootDir, VAULT_DIR);
477
+ const dir = anchorsDir(rootDir);
478
+ const maybeIdentity = readAnchorIdentity(rootDir);
479
+ if (maybeIdentity === null) {
480
+ throw new Error("No anchor identity — run `usertrust anchor init` first");
481
+ }
482
+ // Non-null binding usable inside closures (control-flow narrowing of the
483
+ // original is lost across the async emit() closure below).
484
+ const identity = maybeIdentity;
485
+ const vaultId = identity.vaultId;
486
+ const signer = resolveSigner(config.signer, vaultId);
487
+ const sinks = (config.sinks ?? []).map((sink) => createSink(sink, rootDir));
488
+ const retries = config.publishRetries ?? 5;
489
+ let anchorSkips = 0;
490
+ let publishFailures = 0;
491
+ let degraded = false;
492
+ let lastEmitError = null;
493
+ let timer = null;
494
+ const inFlight = new Set();
495
+ /** Mirror append: the canonical bytes the record hash covers, fsync'd. */
496
+ function appendToMirror(record) {
497
+ const fd = openSync(join(dir, "anchors.jsonl"), "a", 0o600);
498
+ try {
499
+ writeSync(fd, `${canonicalize(record)}\n`);
500
+ fsyncSync(fd);
501
+ }
502
+ finally {
503
+ closeSync(fd);
504
+ }
505
+ }
506
+ // Serialize publish cycles so an overlapping cycle cannot interleave the
507
+ // outbox drain and double-publish a record.
508
+ let publishChain = Promise.resolve();
509
+ async function deliverToSinks(record) {
510
+ let lastErr = null;
511
+ for (const sink of sinks) {
512
+ let delivered = false;
513
+ for (let attempt = 0; attempt < retries && !delivered; attempt++) {
514
+ try {
515
+ await sink.publish(record);
516
+ delivered = true;
517
+ }
518
+ catch (err) {
519
+ lastErr = err;
520
+ publishFailures++;
521
+ const backoff = Math.min(30_000, 250 * 2 ** attempt) * (0.5 + Math.random());
522
+ await new Promise((r) => setTimeout(r, backoff));
523
+ }
524
+ }
525
+ if (!delivered) {
526
+ degraded = true;
527
+ throw lastErr instanceof Error
528
+ ? lastErr
529
+ : new Error(`anchor publish failed to sink ${sink.name}`);
530
+ }
531
+ }
532
+ }
533
+ async function publishRecord(record) {
534
+ if (sinks.length === 0) {
535
+ // Pull mode (spec §5.5): mirror-only; a customer job ships records
536
+ // via `usertrust anchor export`. With no sinks there is nothing to
537
+ // deliver, so the outbox entry is not needed.
538
+ try {
539
+ unlinkSync(outboxPath(dir, record.anchorSeq));
540
+ }
541
+ catch {
542
+ /* best effort */
543
+ }
544
+ return;
545
+ }
546
+ // Drain the backlog oldest-first BEFORE (and including) the current
547
+ // record so a transient sink outage cannot leave a permanent
548
+ // anchorSeq gap in the append-only store (spec §5.3). Stop on the
549
+ // first failure to preserve store ordering; the emitter stays degraded
550
+ // until the whole backlog clears.
551
+ const backlog = pendingOutboxRecords(dir);
552
+ const queue = backlog.some((r) => r.anchorSeq === record.anchorSeq)
553
+ ? backlog
554
+ : [...backlog, record].sort((a, b) => a.anchorSeq - b.anchorSeq);
555
+ for (const rec of queue) {
556
+ await deliverToSinks(rec);
557
+ try {
558
+ unlinkSync(outboxPath(dir, rec.anchorSeq));
559
+ }
560
+ catch {
561
+ /* best effort */
562
+ }
563
+ }
564
+ // Clear degraded only when nothing is left undelivered.
565
+ if (outboxSeqs(dir).length === 0)
566
+ degraded = false;
567
+ }
568
+ function trackPublish(record) {
569
+ const p = publishChain
570
+ .then(() => publishRecord(record))
571
+ .catch(() => {
572
+ /* failure already counted; outbox entry retained for retry */
573
+ })
574
+ .finally(() => {
575
+ inFlight.delete(p);
576
+ });
577
+ publishChain = p;
578
+ inFlight.add(p);
579
+ return p;
580
+ }
581
+ async function snapshotHead() {
582
+ for (let attempt = 0; attempt < SNAPSHOT_RETRIES; attempt++) {
583
+ const meta = readMeta(rootDir);
584
+ if (meta === "unreadable") {
585
+ // .meta is truncate-rewritten in place; a cross-process reader
586
+ // can catch it mid-write. Retry, then skip the cycle.
587
+ await new Promise((r) => setTimeout(r, 25 * (attempt + 1)));
588
+ continue;
589
+ }
590
+ const hashes = gatherOrderedEventHashes(vaultPath);
591
+ if (meta === "absent") {
592
+ // Legacy vault: fall back to the ordered segment tail.
593
+ if (hashes.length === 0)
594
+ return { skip: "empty" };
595
+ return {
596
+ sequence: hashes.length,
597
+ lastHash: hashes[hashes.length - 1],
598
+ hashes,
599
+ };
600
+ }
601
+ if (meta.sequence === 0 || hashes.length === 0)
602
+ return { skip: "empty" };
603
+ // Guard against a torn read of the tail line during a concurrent
604
+ // append: the prefix [0..sequence) is stable, the tail may not be.
605
+ if (hashes.length >= meta.sequence && hashes[meta.sequence - 1] === meta.lastHash) {
606
+ return { sequence: meta.sequence, lastHash: meta.lastHash, hashes };
607
+ }
608
+ await new Promise((r) => setTimeout(r, 25 * (attempt + 1)));
609
+ }
610
+ return { skip: "snapshot-unstable" };
611
+ }
612
+ async function emit(rotation) {
613
+ const release = tryAcquireAnchorLock(dir);
614
+ if (release === null) {
615
+ anchorSkips++;
616
+ return { emitted: false, reason: "locked" };
617
+ }
618
+ try {
619
+ // Re-read identity fresh: the durable high-water is bumped on disk
620
+ // each emit, so the creation-time closure copy is stale.
621
+ const liveIdentity = readAnchorIdentity(rootDir) ?? identity;
622
+ const snap = await snapshotHead();
623
+ if ("skip" in snap) {
624
+ if (snap.skip !== "empty")
625
+ anchorSkips++;
626
+ return { emitted: false, reason: snap.skip };
627
+ }
628
+ const mirror = readMirrorRecords(dir);
629
+ if (!mirror.present) {
630
+ // Mirror FILE missing while an identity exists: accidental loss
631
+ // or partial restore. Refusing (loudly) beats silently minting a
632
+ // second GENESIS-linked record — permanent fork evidence in an
633
+ // append-only store. `usertrust anchor resume` re-seeds it.
634
+ degraded = true;
635
+ return { emitted: false, reason: "mirror-missing (run `usertrust anchor resume`)" };
636
+ }
637
+ if (mirror.errors.length > 0) {
638
+ // Corrupt-but-present mirror: same accident class as
639
+ // mirror-missing (disk fault / partial restore / torn tail). An
640
+ // anchorSeq allocated from a partial view can re-occupy a
641
+ // published position with divergent content — permanent fork
642
+ // evidence (spec §5.1). Repair/move the mirror aside first.
643
+ degraded = true;
644
+ return {
645
+ emitted: false,
646
+ reason: "mirror-corrupt (repair the mirror, then `usertrust anchor resume`)",
647
+ };
648
+ }
649
+ let tail = mirror.records.at(-1) ?? null;
650
+ // Self-heal: a crash between the outbox write and the mirror append
651
+ // leaves a fully minted record in the outbox that the mirror lacks.
652
+ // Re-append it (validated: ours, signed by our key, contiguous)
653
+ // instead of wedging or re-minting the seq — re-minting would
654
+ // publish divergent content at an occupied position (permanent fork
655
+ // evidence in the append-only store).
656
+ for (const orphan of pendingOutboxRecords(dir).filter((o) => o.anchorSeq > (mirror.records.at(-1)?.anchorSeq ?? 0))) {
657
+ const expectedPrev = tail === null ? GENESIS_HASH : anchorPayloadHash(tail);
658
+ const idKey = publicKeyFromSpkiBase64(liveIdentity.publicKeySpki);
659
+ const orphanOk = orphan.vaultId === vaultId &&
660
+ orphan.anchorSeq === (tail?.anchorSeq ?? 0) + 1 &&
661
+ orphan.prevAnchorHash === expectedPrev &&
662
+ idKey !== null &&
663
+ verifySignatureRaw("ed25519", anchorSigningPreimage(orphan), idKey, orphan.sig);
664
+ if (!orphanOk) {
665
+ degraded = true;
666
+ return {
667
+ emitted: false,
668
+ reason: "outbox-orphan-invalid (inspect audit/anchors/outbox, then `usertrust anchor resume`)",
669
+ };
670
+ }
671
+ appendToMirror(orphan);
672
+ bumpAnchorHighWater(rootDir, orphan.anchorSeq);
673
+ tail = orphan;
674
+ }
675
+ // Durable high-water lives outside the mirror: an emptied mirror
676
+ // (tail null) with a prior anchor recorded must NOT restart at 1.
677
+ const durableHighWater = liveIdentity.lastAnchorSeq ?? 0;
678
+ const highWater = Math.max(tail?.anchorSeq ?? 0, durableHighWater, ...outboxSeqs(dir), 0);
679
+ if ((tail?.anchorSeq ?? 0) < highWater) {
680
+ degraded = true;
681
+ return {
682
+ emitted: false,
683
+ reason: "mirror-behind-highwater (run `usertrust anchor resume`)",
684
+ };
685
+ }
686
+ // Epoch guard: the resolved signer MUST be the chain's current key.
687
+ // A superseded key (e.g. the pre-rotation PEM still at the default
688
+ // path after `anchor rotate`) would mint permanent
689
+ // rotation-continuity MISMATCH evidence in an append-only store.
690
+ const expectedKeyId = tail?.rotation?.nextKeyId ?? tail?.keyId ?? liveIdentity.keyId;
691
+ if (signer.keyId !== expectedKeyId) {
692
+ degraded = true;
693
+ return {
694
+ emitted: false,
695
+ reason: `stale-signer-key: signer ${signer.keyId} is not the current epoch key ${expectedKeyId} — point USERTRUST_ANCHOR_KEY/--key-file at the post-rotation private key`,
696
+ };
697
+ }
698
+ // Vault-behind guard (symmetric to mirror-behind): an events head
699
+ // BELOW the anchored treeSize means the event log was rolled back
700
+ // (partial restore) — emitting would publish a decreasing-treeSize
701
+ // anchor: permanent rollback evidence from an honest accident.
702
+ if (tail !== null && snap.sequence < tail.treeSize) {
703
+ degraded = true;
704
+ return {
705
+ emitted: false,
706
+ reason: `vault-behind-anchors: events head ${snap.sequence} < anchored treeSize ${tail.treeSize} — restore the newer events before anchoring`,
707
+ };
708
+ }
709
+ if (tail !== null && tail.treeSize === snap.sequence && rotation === undefined) {
710
+ return { emitted: false, reason: "no-new-events" };
711
+ }
712
+ const tree = buildMerkleTree(snap.hashes.slice(0, snap.sequence));
713
+ if (tree.root === undefined) {
714
+ return { emitted: false, reason: "empty" };
715
+ }
716
+ const unsigned = {
717
+ v: 1,
718
+ vaultId,
719
+ anchorSeq: highWater + 1,
720
+ prevAnchorHash: tail === null ? GENESIS_HASH : anchorPayloadHash(tail),
721
+ treeSize: snap.sequence,
722
+ lastHash: snap.lastHash,
723
+ merkleRoot: tree.root,
724
+ timestamp: new Date().toISOString(),
725
+ keyId: signer.keyId,
726
+ ...(rotation !== undefined ? { rotation } : {}),
727
+ };
728
+ const sig = await signer.sign(canonicalize(unsigned));
729
+ const record = { ...unsigned, sig };
730
+ const check = parseAnchorRecord(canonicalize(record));
731
+ if (check.record === null) {
732
+ throw new Error(`emitter produced an invalid record: ${check.error}`);
733
+ }
734
+ // Durability order: outbox FIRST (fsync'd delivery intent), then the
735
+ // mirror append, then the high-water bump. A crash after only the
736
+ // outbox write is self-healed above (orphan re-appended to the
737
+ // mirror); the reverse order would strand a mirrored-but-never-
738
+ // published record — a permanent anchor-chain gap in the store that
739
+ // verifies as tampering.
740
+ writeOutboxEntry(dir, record);
741
+ appendToMirror(record);
742
+ // High-water AFTER the mirror append is fsync'd, so a crash leaves
743
+ // high-water ≤ mirror tail (never ahead of what exists).
744
+ bumpAnchorHighWater(rootDir, record.anchorSeq);
745
+ trackPublish(record);
746
+ return { emitted: true, record };
747
+ }
748
+ finally {
749
+ release();
750
+ }
751
+ }
752
+ function status() {
753
+ const mirror = readMirrorRecords(dir);
754
+ const tail = mirror.records.at(-1) ?? null;
755
+ const meta = readMeta(rootDir);
756
+ const headSeq = typeof meta === "object" ? meta.sequence : null;
757
+ const ts = tail === null ? Number.NaN : Date.parse(tail.timestamp);
758
+ return {
759
+ lastAnchor: tail === null
760
+ ? null
761
+ : { anchorSeq: tail.anchorSeq, treeSize: tail.treeSize, timestamp: tail.timestamp },
762
+ eventsSinceLastAnchor: headSeq === null ? 0 : Math.max(0, headSeq - (tail?.treeSize ?? 0)),
763
+ msSinceLastAnchor: Number.isFinite(ts) ? Math.max(0, Date.now() - ts) : null,
764
+ outboxDepth: outboxSeqs(dir).length,
765
+ anchorSkips,
766
+ publishFailures,
767
+ degraded,
768
+ lastEmitError,
769
+ };
770
+ }
771
+ return {
772
+ anchorNow: () => emit(),
773
+ rotate: (next) => emit({ nextKeyId: next.keyId, nextPublicKeySpki: next.publicKeySpki }),
774
+ status,
775
+ start() {
776
+ if (timer !== null)
777
+ return;
778
+ const everyMs = config.cadence?.everyMs ?? DEFAULT_ANCHOR_EVERY_MS;
779
+ const everyEvents = config.cadence?.everyEvents ?? DEFAULT_ANCHOR_EVERY_EVENTS;
780
+ let lastAnchorAt = Date.now();
781
+ const tick = () => {
782
+ const meta = readMeta(rootDir);
783
+ const headSeq = typeof meta === "object" ? meta.sequence : 0;
784
+ const mirror = readMirrorRecords(dir);
785
+ const anchored = mirror.records.at(-1)?.treeSize ?? 0;
786
+ const due = headSeq - anchored >= everyEvents ||
787
+ (headSeq > anchored && Date.now() - lastAnchorAt >= everyMs);
788
+ if (due) {
789
+ lastAnchorAt = Date.now();
790
+ // A scheduler-driven emit() must never reject into the void:
791
+ // an unhandled rejection (KMS signer outage, ENOSPC on the
792
+ // mirror, self-check throw) would crash the host process the
793
+ // emitter is embedded in. Capture it as a loud, inspectable
794
+ // degraded signal instead (spec §5.2/constraints §4.4).
795
+ emit()
796
+ .then((r) => {
797
+ if (r.emitted)
798
+ lastEmitError = null;
799
+ })
800
+ .catch((err) => {
801
+ anchorSkips++;
802
+ degraded = true;
803
+ lastEmitError = err instanceof Error ? err.message : String(err);
804
+ });
805
+ }
806
+ };
807
+ // Check at a fraction of the interval so the everyEvents trigger
808
+ // fires promptly; unref so the scheduler never holds the process.
809
+ timer = setInterval(tick, Math.max(250, Math.min(everyMs, 5_000)));
810
+ timer.unref?.();
811
+ },
812
+ async stop() {
813
+ if (timer !== null) {
814
+ clearInterval(timer);
815
+ timer = null;
816
+ }
817
+ await Promise.allSettled([...inFlight]);
818
+ },
819
+ exportSince(since) {
820
+ return readMirrorRecords(dir).records.filter((r) => r.anchorSeq > since);
821
+ },
822
+ };
823
+ }
824
+ /**
825
+ * Re-seed a lost mirror from the store's newest record (spec §5.1 step 4).
826
+ * The record must belong to this vault's identity; it becomes the mirror
827
+ * tail so the next emission allocates anchorSeq correctly instead of minting
828
+ * a second GENESIS-linked chain.
829
+ */
830
+ export function resumeAnchorMirror(rootDir, latestRecordRaw) {
831
+ const identity = readAnchorIdentity(rootDir);
832
+ if (identity === null) {
833
+ throw new Error("No anchor identity — run `usertrust anchor init` first");
834
+ }
835
+ const { record, error } = parseAnchorRecord(latestRecordRaw.trim());
836
+ if (record === null) {
837
+ throw new Error(`resume: supplied record is invalid: ${error}`);
838
+ }
839
+ if (record.vaultId !== identity.vaultId) {
840
+ throw new Error(`resume: record vaultId ${record.vaultId} does not match this vault (${identity.vaultId})`);
841
+ }
842
+ // The record becomes the trusted mirror tail (next emission links its
843
+ // prevAnchorHash to it), so it MUST be provably ours: signed under the
844
+ // CURRENT identity key, or a rotation handoff INTO the current key.
845
+ // vaultId alone is public (printed by `anchor status`) — accepting any
846
+ // well-formed record would let a stale/forged file seed a fork.
847
+ const idKey = publicKeyFromSpkiBase64(identity.publicKeySpki);
848
+ const sigOk = idKey !== null &&
849
+ record.keyId === identity.keyId &&
850
+ verifySignatureRaw("ed25519", anchorSigningPreimage(record), idKey, record.sig);
851
+ const handoffOk = record.rotation !== undefined && record.rotation.nextKeyId === identity.keyId;
852
+ if (!sigOk && !handoffOk) {
853
+ throw new Error("resume: record does not verify under this vault's current anchor key — fetch the STORE'S newest record");
854
+ }
855
+ if ((identity.lastAnchorSeq ?? 0) > record.anchorSeq) {
856
+ throw new Error(`resume: durable high-water ${identity.lastAnchorSeq} exceeds the supplied record (${record.anchorSeq}) — fetch the store's newest record`);
857
+ }
858
+ const dir = anchorsDir(rootDir);
859
+ const mirror = readMirrorRecords(dir);
860
+ const tail = mirror.records.at(-1);
861
+ if (tail !== undefined && tail.anchorSeq >= record.anchorSeq) {
862
+ throw new Error(`resume: mirror already at anchorSeq ${tail.anchorSeq} (supplied ${record.anchorSeq})`);
863
+ }
864
+ mkdirSync(dir, { recursive: true });
865
+ const fd = openSync(join(dir, "anchors.jsonl"), "a", 0o600);
866
+ try {
867
+ writeSync(fd, `${canonicalize(record)}\n`);
868
+ fsyncSync(fd);
869
+ }
870
+ finally {
871
+ closeSync(fd);
872
+ }
873
+ // Re-seeding advances the durable high-water so the next emission allocates
874
+ // from the re-seeded tail, not from a stale value.
875
+ bumpAnchorHighWater(rootDir, record.anchorSeq);
876
+ return record;
877
+ }
878
+ /**
879
+ * Mint a successor keypair for `usertrust anchor rotate` (spec §6): the new
880
+ * private key is written OUTSIDE the vault; the caller emits the cross-signed
881
+ * rotation record via AnchorEmitter.rotate() and then updates identity.json.
882
+ */
883
+ export function mintSuccessorKey(vaultId) {
884
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
885
+ const keyId = keyIdFromKeyObject(publicKey);
886
+ const keyFile = join(homedir(), ".usertrust", "keys", `${vaultId}.anchor.${Date.now()}.pem`);
887
+ mkdirSync(join(keyFile, ".."), { recursive: true, mode: 0o700 });
888
+ writeFileSync(keyFile, privateKey.export({ type: "pkcs8", format: "pem" }), {
889
+ mode: 0o600,
890
+ });
891
+ return {
892
+ keyId,
893
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }),
894
+ publicKeySpki: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
895
+ keyFile,
896
+ };
897
+ }
898
+ /**
899
+ * Update identity.json after a successful rotation emission. The superseded key
900
+ * is APPENDED to the history rather than replaced: records signed under it may
901
+ * still be sitting in the outbox, and a witness sink must be able to name the
902
+ * key that actually signed each one long after the epoch moved on.
903
+ */
904
+ export function recordRotatedIdentity(rootDir, next) {
905
+ const identity = readAnchorIdentity(rootDir);
906
+ if (identity === null) {
907
+ throw new Error("No anchor identity to rotate");
908
+ }
909
+ // An identity minted before key history seeds one from its current epoch, so
910
+ // the pre-rotation key is not lost by upgrading mid-life.
911
+ const history = identity.keyHistory ?? [
912
+ { keyId: identity.keyId, publicKeySpki: identity.publicKeySpki },
913
+ ];
914
+ const updated = {
915
+ ...identity,
916
+ keyId: next.keyId,
917
+ publicKeySpki: next.publicKeySpki,
918
+ keyHistory: [...history.filter((entry) => entry.keyId !== next.keyId), next],
919
+ };
920
+ writeIdentityFile(rootDir, updated);
921
+ }
922
+ //# sourceMappingURL=anchor.js.map