codehere 0.6.2 → 0.7.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 (70) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +28 -4
  4. package/dist/application/use-cases/generate-narration.js +11 -0
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +211 -16
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
  23. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  24. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  25. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  26. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  27. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  28. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  29. package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
  30. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  31. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  32. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  33. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  34. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  35. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  36. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  37. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  38. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  39. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  40. package/dist/infrastructure/orchestration/session-manager.js +94 -6
  41. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  42. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  43. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  44. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  45. package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
  46. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  47. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  48. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  49. package/dist/infrastructure/protocols/verify-page.js +941 -0
  50. package/dist/infrastructure/security/trust-scorer.js +76 -1
  51. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  52. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  53. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  54. package/dist/monitoring.js +1 -1
  55. package/dist/presentation/cli/commands/audit-command.js +126 -0
  56. package/dist/presentation/cli/commands/config-command.js +2 -2
  57. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  58. package/dist/presentation/cli/commands/help-command.js +51 -1
  59. package/dist/presentation/cli/commands/report-command.js +6 -4
  60. package/dist/presentation/cli/commands/setup-command.js +71 -18
  61. package/dist/presentation/cli/commands/status-command.js +0 -4
  62. package/dist/presentation/cli/commands/thread-command.js +288 -0
  63. package/dist/presentation/cli/commands/verify-command.js +233 -0
  64. package/dist/presentation/cli/error-display.js +1 -1
  65. package/dist/presentation/cli/repl.js +32 -1
  66. package/dist/presentation/cli/task-deliverable.js +127 -0
  67. package/dist/testing/cassette.js +125 -0
  68. package/dist/testing/replay-adapter.js +166 -0
  69. package/dist/ui.js +18 -1
  70. package/package.json +4 -2
package/README.md CHANGED
@@ -5,12 +5,14 @@
5
5
 
6
6
  [![npm](https://img.shields.io/npm/v/codehere)](https://www.npmjs.com/package/codehere)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](../LICENSE)
8
- [![Tests](https://img.shields.io/badge/tests-1196%2F1196-brightgreen)]()
9
- [![Vulnerabilities](https://img.shields.io/badge/vulnerabilities-0-brightgreen)]()
8
+ [![Internal tests](https://img.shields.io/badge/internal_tests-1200%2F1200-brightgreen)]()
9
+ [![Honest status](https://img.shields.io/badge/v0.7--honest--reframe-2026--05--19-blue)](../docs/TRUTHFUL_STATE_2026-05-19.md)
10
10
 
11
11
  **Codehere is the vendor-neutral layer between you and the coding agents you already use** — Claude Code, Codex, Aider, Gemini, OpenCode. One `npm install`. Local-first, OSS, no account, no IAM, no compliance prereqs. Switch agent, keep your memory. End the day with one Final Report covering everything they did.
12
12
 
13
- All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) get session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call. The *memory*, *audit log*, *failover*, and *Final Report* surfaces ship across all 5 adapters today.
13
+ All 5 adapters (Claude Code, Codex, Aider, Gemini, OpenCode) ship adapter code for session-tracing, Final Reports, and trust scoring. Claude Code additionally gets full pre-execution safety-hook enforcement on every tool call via the Claude Agent SDK. The *memory*, *audit log*, *failover*, and *Final Report* surfaces are wired across all 5 adapters today.
14
+
15
+ > **Honest status (2026-05-19)** — The 1200 internal tests verify codehere's code against codehere's own assumptions about what each adapter emits. They do **not** systematically verify against real-adapter outputs in production. Real-adapter contract verification is the v0.7 release work — see [`docs/TRUTHFUL_STATE_2026-05-19.md`](../docs/TRUTHFUL_STATE_2026-05-19.md) for the verified / assumed / unverified classification of every surface.
14
16
 
15
17
  The enterprise tier is owned by [OpenAI Frontier](https://openai.com/index/introducing-openai-frontier/) (8-14 weeks of deploy prereqs, sales-team gated) and [Anthropic Cowork](https://www.anthropic.com/product/claude-cowork) (paid Claude subscription, single-vendor). Codehere fills the gap they structurally can't reach: **the individual / small-team / no-IT-department tier, vendor-neutral**.
16
18
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Per-domain trust grade for W-PASS write targets · v0.9 must-ship.
3
+ *
4
+ * Demo v12 + locked v1 tier scope: "linear.app A · *.atlassian.net B ·
5
+ * unknown F · first-write confirmation per domain."
6
+ *
7
+ * Two pieces of state:
8
+ * 1. WELL_KNOWN_GRADES · hardcoded · the platforms we've vetted ourselves
9
+ * ship with grade A. Subdomain wildcards get B. Everything else
10
+ * defaults to F (forces explicit user confirmation on first write).
11
+ * 2. USER_TRUSTED_DOMAINS · ~/.codehere/keys/trusted-domains.json ·
12
+ * domains the user has explicitly trusted after a first-write prompt.
13
+ * Domains here promote from F → B.
14
+ *
15
+ * Pure logic. CLI + HTTP wrap it.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
18
+ import { homedir } from 'os';
19
+ import { dirname, join } from 'path';
20
+ const WELL_KNOWN_GRADES = [
21
+ // Grade A · platforms codehere has shipped+tested adapters for
22
+ { pattern: /^api\.linear\.app$/i, grade: 'A', reason: 'official Linear API · codehere ships a vetted adapter' },
23
+ { pattern: /^api\.github\.com$/i, grade: 'A', reason: 'official GitHub API · codehere ships a vetted adapter' },
24
+ { pattern: /^api\.figma\.com$/i, grade: 'A', reason: 'official Figma API · codehere ships a vetted adapter' },
25
+ // Grade B · same vendor, surrounding subdomains
26
+ { pattern: /^[a-z0-9-]+\.linear\.app$/i, grade: 'B', reason: 'Linear subdomain · adjacent to the vetted API' },
27
+ { pattern: /^[a-z0-9-]+\.github\.com$/i, grade: 'B', reason: 'GitHub subdomain · adjacent to the vetted API' },
28
+ { pattern: /^[a-z0-9-]+\.figma\.com$/i, grade: 'B', reason: 'Figma subdomain · adjacent to the vetted API' },
29
+ {
30
+ pattern: /^[a-z0-9-]+\.atlassian\.net$/i,
31
+ grade: 'B',
32
+ reason: 'Atlassian-hosted instance · vendor known but instance not vetted',
33
+ },
34
+ // Grade C · public hosts known to be commonly used
35
+ { pattern: /^raw\.githubusercontent\.com$/i, grade: 'C', reason: 'GitHub-hosted raw content · static, read-mostly' },
36
+ ];
37
+ function trustedDomainsPath() {
38
+ const home = process.env.CODEHERE_HOME || homedir();
39
+ return join(home, '.codehere', 'keys', 'trusted-domains.json');
40
+ }
41
+ export function loadTrustedDomains() {
42
+ const path = trustedDomainsPath();
43
+ if (!existsSync(path))
44
+ return { schema: 1, domains: {} };
45
+ try {
46
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
47
+ if (raw && raw.schema === 1 && typeof raw.domains === 'object')
48
+ return raw;
49
+ }
50
+ catch {
51
+ /* fall through · empty trust store */
52
+ }
53
+ return { schema: 1, domains: {} };
54
+ }
55
+ export function trustDomain(host, note) {
56
+ const file = loadTrustedDomains();
57
+ file.domains[host.toLowerCase()] = { addedAt: Date.now(), note };
58
+ mkdirSync(dirname(trustedDomainsPath()), { recursive: true });
59
+ writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
60
+ }
61
+ export function untrustDomain(host) {
62
+ const file = loadTrustedDomains();
63
+ const key = host.toLowerCase();
64
+ if (!file.domains[key])
65
+ return false;
66
+ delete file.domains[key];
67
+ writeFileSync(trustedDomainsPath(), JSON.stringify(file, null, 2) + '\n', 'utf-8');
68
+ return true;
69
+ }
70
+ export function gradeDomain(host) {
71
+ const userFile = loadTrustedDomains();
72
+ const userTrusted = !!userFile.domains[host.toLowerCase()];
73
+ for (const rule of WELL_KNOWN_GRADES) {
74
+ if (rule.pattern.test(host)) {
75
+ return {
76
+ host,
77
+ grade: rule.grade,
78
+ reason: rule.reason,
79
+ userTrusted,
80
+ requiresFirstWriteConfirmation: false,
81
+ };
82
+ }
83
+ }
84
+ if (userTrusted) {
85
+ return {
86
+ host,
87
+ grade: 'B',
88
+ reason: 'previously trusted by you · stored in trusted-domains.json',
89
+ userTrusted: true,
90
+ requiresFirstWriteConfirmation: false,
91
+ };
92
+ }
93
+ return {
94
+ host,
95
+ grade: 'F',
96
+ reason: 'unknown domain · not in well-known list · not previously trusted',
97
+ userTrusted: false,
98
+ requiresFirstWriteConfirmation: true,
99
+ };
100
+ }
101
+ /** Extract the host from a fully-resolved HTTPS URL. */
102
+ export function hostOf(url) {
103
+ try {
104
+ return new URL(url).host;
105
+ }
106
+ catch {
107
+ return '';
108
+ }
109
+ }
110
+ //# sourceMappingURL=domain-trust.js.map
@@ -24,6 +24,8 @@
24
24
  * Claude Squad, AI-Agents-Orchestrator) advertise.
25
25
  */
26
26
  import { spawn } from 'child_process';
27
+ import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
28
+ import { mockFinalReport } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
27
29
  const PROMPT_TEMPLATE = (i) => `You are a PM reporting back to a developer about what their AI coding agent
28
30
  just did. Write 3 short paragraphs in plain English. No headers, no bullet
29
31
  lists, no markdown — just three paragraphs separated by blank lines.
@@ -41,8 +43,12 @@ Paragraph 1: What the agent ATTEMPTED. Verb-led. Describe steps as listed.
41
43
  Mention specific filenames ONLY if they appear in the data
42
44
  below. 1-3 sentences.
43
45
  Paragraph 2: What was TOUCHED. Reference ONLY the "Files changed" list
44
- below. If the list is empty, say "No files were changed."
45
- 1-2 sentences.
46
+ below. If the list is empty AND "File tracking" says git is
47
+ available, say "No files were changed." If "File tracking" says
48
+ git is NOT available, say codehere could not track file changes
49
+ because this folder is not a git repository — do NOT say no files
50
+ were changed, because the agent may well have changed files
51
+ codehere cannot see. 1-2 sentences.
46
52
  Paragraph 3: What the developer should LOOK AT. If steps + files data show
47
53
  nothing risky, say so explicitly ("nothing to verify; the
48
54
  session was read-only" or similar). 1-2 sentences.
@@ -59,6 +65,8 @@ Status: ${i.status}
59
65
  Steps the agent took:
60
66
  ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') : '(no structured steps captured)'}
61
67
 
68
+ File tracking: ${i.artifacts?.noGitTracking ? 'git NOT available (this folder is not a git repository — file changes cannot be tracked)' : 'git available'}
69
+
62
70
  Files changed:
63
71
  ${i.artifacts?.files?.length
64
72
  ? i.artifacts.files
@@ -101,7 +109,7 @@ function buildErrorRecoveryReport(input, durationMs) {
101
109
  `What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
102
110
  `or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
103
111
  `from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
104
- `No files were changed in this attempt.`,
112
+ `codehere couldn't confirm any file changes from this attempt.`,
105
113
  generatedAt: Date.now(),
106
114
  durationMs,
107
115
  };
@@ -116,6 +124,12 @@ function buildErrorRecoveryReport(input, durationMs) {
116
124
  * a possibly-unhelpful LLM summary.
117
125
  */
118
126
  export async function generateFinalReport(input) {
127
+ // CODEHERE_MOCK — synthesize a grounded report with zero API so the full
128
+ // demo flow (incl. this headline deliverable + the verify cross-check) runs
129
+ // under mock. Errors still get the deterministic recovery report below.
130
+ if (isMockMode() && input.status !== 'error') {
131
+ return mockFinalReport(input);
132
+ }
119
133
  // Short-circuit on errored sessions — don't waste tokens summarizing nothing.
120
134
  if (input.status === 'error') {
121
135
  return buildErrorRecoveryReport(input, 0);
@@ -155,7 +169,17 @@ function tryProvider(binary, prompt, sessionId) {
155
169
  settled = true;
156
170
  resolve(result);
157
171
  };
158
- const child = spawn(binary, ['--bare', '-p', prompt], {
172
+ // Provider args per-binary:
173
+ // - `claude-free` (FCC proxy binary) needs `--bare` to skip session
174
+ // setup that the proxy doesn't implement.
175
+ // - Standard `claude` (the Claude Code Max CLI) MUST NOT use `--bare`
176
+ // because `--bare` explicitly rejects OAuth and keychain auth — and
177
+ // Claude Code Max users authenticate via OAuth. With `--bare`, the
178
+ // fallback path returned empty silently, no Final Report ever
179
+ // reached the UI, and the dashboard showed `✓ Final Report` on a
180
+ // missing artifact. (May 21, 2026 — root cause of CRIT #1.)
181
+ const args = binary === 'claude-free' ? ['--bare', '-p', prompt] : ['-p', prompt];
182
+ const child = spawn(binary, args, {
159
183
  stdio: ['ignore', 'pipe', 'pipe'],
160
184
  });
161
185
  const timeout = setTimeout(() => {
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { spawn } from 'child_process';
23
23
  import { loadTemplate, renderTemplate } from './narrator-prompt-template.js';
24
+ import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
25
+ import { mockNarration } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
24
26
  /** v0.6 S6 — prompt template lifted into ~/.codehere/narrator-prompt.md so
25
27
  * the user can edit the agent's voice. loadTemplate() falls back to the
26
28
  * baked-in default on missing file or missing required placeholders, so
@@ -37,6 +39,15 @@ function selectProviderBinary() {
37
39
  return ['claude-free', 'claude'];
38
40
  }
39
41
  export async function generateNarration(input) {
42
+ if (isMockMode()) {
43
+ const m = mockNarration({ steps: input.steps });
44
+ return {
45
+ summary: m.summary,
46
+ generatedAt: Date.now(),
47
+ durationMs: m.durationMs,
48
+ stepCountAtSnapshot: input.steps.length,
49
+ };
50
+ }
40
51
  const prompt = buildPrompt(input);
41
52
  const candidates = selectProviderBinary();
42
53
  for (const binary of candidates) {
@@ -21,6 +21,8 @@
21
21
  * available; degrades silently to "(untitled)" when no provider responds.
22
22
  */
23
23
  import { spawn } from 'child_process';
24
+ import { isMockMode } from '../../infrastructure/orchestration/mocks/resolve-agent-spawn.js';
25
+ import { mockTitle } from '../../infrastructure/orchestration/mocks/mock-synthesis.js';
24
26
  const TITLE_TIMEOUT_MS = 15_000; // titles are short; abort fast if stuck
25
27
  const PROMPT_TEMPLATE = (firstPrompt, cwd) => `Generate a 4-6 word project title from this first user prompt.
26
28
 
@@ -163,6 +165,8 @@ export function sanitizeTitle(raw) {
163
165
  * Never throws — failure modes return null.
164
166
  */
165
167
  export async function generateThreadTitle(firstPrompt, cwd) {
168
+ if (isMockMode())
169
+ return sanitizeTitle(mockTitle(firstPrompt, cwd)) || null;
166
170
  const prompt = PROMPT_TEMPLATE(firstPrompt, cwd);
167
171
  for (const binary of selectProviderBinary()) {
168
172
  const result = await tryProvider(binary, prompt);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Thread export · v0.8 S4 · signed-payload "GitHub for agent sessions"
3
+ *
4
+ * Produces a self-contained, Ed25519-signed bundle of one PersistedSession
5
+ * that a colleague can verify and replay locally. Local-first by design:
6
+ * never relayed through codehere's infrastructure · the user shares the
7
+ * file (or signed URL) by their preferred channel (Slack, email, PR
8
+ * description) and the recipient runs `codehere thread import <file>`.
9
+ *
10
+ * The signature covers a CANONICAL JSON form of the bundle body so two
11
+ * machines that re-serialize the same data get the same hash. Verification
12
+ * recomputes the canonical form, re-derives the hash, checks the signature
13
+ * against the included public key, and returns one of:
14
+ * - 'valid' · signature matches · safe to replay
15
+ * - 'tampered' · signature does NOT match · refuse
16
+ * - 'malformed' · bundle is structurally wrong · refuse
17
+ * - 'unknown_key' · signature valid but public key not in user's trust
18
+ * store · prompt user to trust+continue or refuse
19
+ *
20
+ * Pure logic. The CLI + HTTP routes wrap this and do I/O.
21
+ */
22
+ import { sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, createPrivateKey, createPublicKey, } from 'crypto';
23
+ /* ──────────────────────────────────────────────────────────────────────── */
24
+ /* Canonicalization · deterministic JSON form so signatures match */
25
+ /* ──────────────────────────────────────────────────────────────────────── */
26
+ /**
27
+ * Stable JSON: object keys sorted, no whitespace, primitives preserved.
28
+ * Two machines that build the same body MUST produce the same bytes for
29
+ * the signature to verify. We deliberately don't use JSON.stringify with
30
+ * a replacer because Node's serializer doesn't guarantee key order across
31
+ * versions/realms.
32
+ */
33
+ export function canonicalize(value) {
34
+ if (value === null)
35
+ return 'null';
36
+ if (typeof value === 'number') {
37
+ if (!Number.isFinite(value))
38
+ throw new Error(`cannot canonicalize non-finite number: ${value}`);
39
+ return JSON.stringify(value);
40
+ }
41
+ if (typeof value === 'string' || typeof value === 'boolean')
42
+ return JSON.stringify(value);
43
+ if (Array.isArray(value)) {
44
+ return '[' + value.map(canonicalize).join(',') + ']';
45
+ }
46
+ if (typeof value === 'object') {
47
+ const obj = value;
48
+ const keys = Object.keys(obj).sort();
49
+ const parts = keys
50
+ .filter((k) => obj[k] !== undefined) // undefined → drop the key (JSON convention)
51
+ .map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]));
52
+ return '{' + parts.join(',') + '}';
53
+ }
54
+ if (value === undefined)
55
+ return 'null';
56
+ throw new Error(`cannot canonicalize type: ${typeof value}`);
57
+ }
58
+ /** Generate a fresh Ed25519 keypair · used for first-time setup. */
59
+ export function generateKeypair() {
60
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
61
+ return { publicKey, privateKey };
62
+ }
63
+ /** Sign a bundle body and produce the full ThreadBundle.
64
+ * Ed25519 is NOT a hash-then-sign scheme · pass the message directly to
65
+ * crypto.sign(null, msg, key) · the algorithm is implied by the key. */
66
+ export function signBundle(body, keys) {
67
+ const canonical = canonicalize(body);
68
+ const sig = cryptoSign(null, Buffer.from(canonical, 'utf-8'), keys.privateKey);
69
+ const pubRaw = keys.publicKey.export({ format: 'der', type: 'spki' });
70
+ return {
71
+ body,
72
+ signature: sig.toString('base64'),
73
+ publicKey: pubRaw.toString('base64'),
74
+ alg: 'ed25519',
75
+ };
76
+ }
77
+ /**
78
+ * Verify a bundle. Returns one of:
79
+ * - valid · signature matches AND the publicKey is in trustedKeys
80
+ * - valid-untrusted · signature matches but the key isn't in trustedKeys
81
+ * (returned with status='valid' but trusted=false)
82
+ * - tampered · signature doesn't match the recomputed body bytes
83
+ * - malformed · bundle structure broken
84
+ */
85
+ export function verifyBundle(bundle, trustedKeys = new Set()) {
86
+ if (!isBundle(bundle))
87
+ return { status: 'malformed', reason: 'bundle shape is invalid' };
88
+ if (bundle.alg !== 'ed25519')
89
+ return { status: 'malformed', reason: `unsupported alg: ${bundle.alg}` };
90
+ try {
91
+ const canonical = canonicalize(bundle.body);
92
+ const pubBuf = Buffer.from(bundle.publicKey, 'base64');
93
+ const sigBuf = Buffer.from(bundle.signature, 'base64');
94
+ const pubKey = createPublicKey({ key: pubBuf, format: 'der', type: 'spki' });
95
+ const ok = cryptoVerify(null, Buffer.from(canonical, 'utf-8'), pubKey, sigBuf);
96
+ if (!ok)
97
+ return { status: 'tampered', reason: 'signature does not match body' };
98
+ const trusted = trustedKeys.has(bundle.publicKey);
99
+ if (!trusted) {
100
+ return { status: 'unknown_key', bundle, publicKey: bundle.publicKey };
101
+ }
102
+ return { status: 'valid', bundle, trusted: true };
103
+ }
104
+ catch (err) {
105
+ return { status: 'malformed', reason: err instanceof Error ? err.message : String(err) };
106
+ }
107
+ }
108
+ function isBundle(x) {
109
+ if (typeof x !== 'object' || x === null)
110
+ return false;
111
+ const obj = x;
112
+ if (typeof obj.signature !== 'string')
113
+ return false;
114
+ if (typeof obj.publicKey !== 'string')
115
+ return false;
116
+ if (typeof obj.alg !== 'string')
117
+ return false;
118
+ const body = obj.body;
119
+ if (typeof body !== 'object' || body === null)
120
+ return false;
121
+ const b = body;
122
+ if (b.schema !== 1)
123
+ return false;
124
+ if (b.kind !== 'thread')
125
+ return false;
126
+ if (typeof b.sessionId !== 'string')
127
+ return false;
128
+ if (typeof b.session !== 'object' || b.session === null)
129
+ return false;
130
+ return true;
131
+ }
132
+ /* ──────────────────────────────────────────────────────────────────────── */
133
+ /* Key derivation helpers · PEM encoding for storage */
134
+ /* ──────────────────────────────────────────────────────────────────────── */
135
+ export function exportPrivatePem(key) {
136
+ return key.export({ format: 'pem', type: 'pkcs8' }).toString();
137
+ }
138
+ export function exportPublicPem(key) {
139
+ return key.export({ format: 'pem', type: 'spki' }).toString();
140
+ }
141
+ export function importPrivatePem(pem) {
142
+ return createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' });
143
+ }
144
+ export function importPublicPem(pem) {
145
+ return createPublicKey({ key: pem, format: 'pem', type: 'spki' });
146
+ }
147
+ /** Compute the base64 publicKey string for a KeyObject · same encoding as in bundles. */
148
+ export function publicKeyToBase64(key) {
149
+ return key.export({ format: 'der', type: 'spki' }).toString('base64');
150
+ }
151
+ /** Short fingerprint used in human-readable output. */
152
+ export function shortFingerprint(publicKeyB64) {
153
+ return publicKeyB64.slice(0, 8) + '...' + publicKeyB64.slice(-4);
154
+ }
155
+ //# sourceMappingURL=thread-export.js.map