hivecode-mcp 0.14.1 → 0.14.3
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/substrate.js
CHANGED
|
@@ -1,252 +1,252 @@
|
|
|
1
|
-
// substrate.js — the Hivecode convergent semantic substrate.
|
|
2
|
-
//
|
|
3
|
-
// This is the primitive the whole project has been building toward. It is NOT a
|
|
4
|
-
// connection protocol (that is MCP / A2A — a wire that carries messages). It is a
|
|
5
|
-
// MEDIUM: a shared state that agents write into, where the medium itself enforces
|
|
6
|
-
// guarantees no channel can. Coordination is a property of the medium, not of a
|
|
7
|
-
// controller.
|
|
8
|
-
//
|
|
9
|
-
// The unit of exchange is not a message or a tool call — it is a CHANGE: a triple
|
|
10
|
-
// (intent, patch, provenance)
|
|
11
|
-
// published against the medium's current state. On every publish the medium enforces
|
|
12
|
-
// three invariants, and refuses the change if any fails:
|
|
13
|
-
//
|
|
14
|
-
// I1 PROVENANCE-VERIFIED — every accepted byte traces to a cryptographically
|
|
15
|
-
// verified author and a DECLARED intent, chained to the state it built on.
|
|
16
|
-
// A forged, unsigned, or mis-attributed change is rejected. (This is the new
|
|
17
|
-
// part — no CRDT, no git, no MCP carries verifiable per-change provenance.)
|
|
18
|
-
//
|
|
19
|
-
// I2 CONVERGENT — merging is deterministic and symmetric (ICR guarantees this),
|
|
20
|
-
// so any number of agents applying the same set of changes in ANY order reach
|
|
21
|
-
// the exact same state. No divergence, no "last writer wins" corruption.
|
|
22
|
-
//
|
|
23
|
-
// I3 NON-REGRESSING — the medium only ever moves to an equal-or-healthier state.
|
|
24
|
-
// A change that would make the code parse worse than it already does (or that
|
|
25
|
-
// silently clobbers another agent's edit) is refused, not landed. The shared
|
|
26
|
-
// state can never become more broken than it was.
|
|
27
|
-
//
|
|
28
|
-
// Everything language-specific (parse, structural merge, intent detection) is
|
|
29
|
-
// delegated to ICR (icr.js). Everything identity-specific (self-certifying keys,
|
|
30
|
-
// fingerprints) reuses the crypto model already proven in token.js. This file adds
|
|
31
|
-
// only the missing layer: provenance as a first-class, verifiable, chained TYPE on
|
|
32
|
-
// every change — the thing that turns "merge + coordinate" into a governed medium.
|
|
33
|
-
|
|
34
|
-
import crypto from 'crypto'
|
|
35
|
-
import { structuralMerge, supports, parses, languageFor } from './icr.js'
|
|
36
|
-
import { keyFingerprint } from './token.js'
|
|
37
|
-
|
|
38
|
-
// --- hashing / canonical encoding ----------------------------------------------
|
|
39
|
-
// A content hash names a state. Two identical texts hash identically on every peer,
|
|
40
|
-
// so a hash is a stable, portable pointer into the provenance DAG.
|
|
41
|
-
const sha = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('base64url')
|
|
42
|
-
|
|
43
|
-
export const contentHash = (text) => sha(text == null ? '' : text)
|
|
44
|
-
export const GENESIS = 'genesis' // the parent of the very first change into an empty medium
|
|
45
|
-
|
|
46
|
-
// The exact bytes a change's signature commits to. Fixed field order (NOT JSON, whose
|
|
47
|
-
// key order is not guaranteed) so the signed message is identical on every peer. The
|
|
48
|
-
// signature binds WHO (author) + on-top-of-WHAT (parent) + WHY (intent) + WHAT
|
|
49
|
-
// (contentHash of the patch text) + WHEN (at). Change any one and the signature breaks.
|
|
50
|
-
function signingMessage(p) {
|
|
51
|
-
return [p.author, p.parent, p.intent || '', p.contentHash, p.at].join('\n')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- identity: self-certifying keypairs (Ed25519) ------------------------------
|
|
55
|
-
// An author IS the fingerprint of their public key (same anchoring token.js uses for
|
|
56
|
-
// secured rooms). You cannot claim to be another agent's id without their private key,
|
|
57
|
-
// and no key registry is needed — the key travels with the change and certifies itself.
|
|
58
|
-
export function genIdentity(name = '') {
|
|
59
|
-
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
60
|
-
const pk = publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
|
61
|
-
const sk = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
|
62
|
-
return { name, pk, sk, id: keyFingerprint(pk) }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// --- authoring: turn a raw edit into a signed, provenance-typed CHANGE ----------
|
|
66
|
-
// `identity` is {pk, sk, id} from genIdentity (or a wrapped key). `base` is the text
|
|
67
|
-
// the agent edited FROM (its known ancestor); `text` is the result. `intent` is the
|
|
68
|
-
// declared reason (carried, verified, and used by ICR's semantic merge).
|
|
69
|
-
export function authorChange({ identity, filename, base, text, intent = '', at }) {
|
|
70
|
-
if (at == null) throw new Error('authorChange: `at` is required (pass a timestamp; the substrate never reads the clock itself so it stays deterministic/replayable)')
|
|
71
|
-
const cHash = contentHash(text)
|
|
72
|
-
const prov = {
|
|
73
|
-
author: identity.id,
|
|
74
|
-
pk: identity.pk,
|
|
75
|
-
parent: contentHash(base), // the state this change was made against
|
|
76
|
-
intent: String(intent || ''),
|
|
77
|
-
contentHash: cHash,
|
|
78
|
-
at,
|
|
79
|
-
}
|
|
80
|
-
const sig = crypto.sign(null, Buffer.from(signingMessage(prov)), crypto.createPrivateKey(identity.sk))
|
|
81
|
-
prov.sig = sig.toString('base64url')
|
|
82
|
-
return { filename, base, text, intent: prov.intent, prov }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// --- I1: verify a change's provenance ------------------------------------------
|
|
86
|
-
// Returns { ok, reason }. Checks, in order: the signature is valid for the carried
|
|
87
|
-
// public key; the author id is the fingerprint of that key (no impersonation); and
|
|
88
|
-
// the signed contentHash actually matches the patch text (no swapping the body after
|
|
89
|
-
// signing). A change that fails any of these is not trusted and is never applied.
|
|
90
|
-
export function verifyProvenance(change) {
|
|
91
|
-
const p = change && change.prov
|
|
92
|
-
if (!p || !p.author || !p.pk || !p.sig || !p.contentHash) return { ok: false, reason: 'missing provenance' }
|
|
93
|
-
if (keyFingerprint(p.pk) !== p.author) return { ok: false, reason: 'author id does not match public key (impersonation)' }
|
|
94
|
-
if (contentHash(change.text) !== p.contentHash) return { ok: false, reason: 'content hash does not match text (tampered body)' }
|
|
95
|
-
let valid = false
|
|
96
|
-
try { valid = crypto.verify(null, Buffer.from(signingMessage(p)), crypto.createPublicKey(p.pk), Buffer.from(p.sig, 'base64url')) }
|
|
97
|
-
catch { valid = false }
|
|
98
|
-
if (!valid) return { ok: false, reason: 'bad signature (forged or altered provenance)' }
|
|
99
|
-
return { ok: true }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// --- parse health: the metric non-regression is defined against -----------------
|
|
103
|
-
// 0 = parses; 1 = does not. (A coarse but honest, language-agnostic health score:
|
|
104
|
-
// the medium refuses to move from a parsing state to a non-parsing one.) For an
|
|
105
|
-
// unsupported language ICR can't judge structure, so we treat it as always-healthy
|
|
106
|
-
// and fall back to last-writer semantics — provenance still applies.
|
|
107
|
-
function health(text, filename) {
|
|
108
|
-
if (!supports(filename)) return 0
|
|
109
|
-
return parses(text, languageFor(filename)) ? 0 : 1
|
|
110
|
-
}
|
|
111
|
-
// Exported for the relay's content-authority check (non-regression on the head).
|
|
112
|
-
export function contentHealth(text, filename) { return health(text, filename) }
|
|
113
|
-
|
|
114
|
-
// --- authoritative head: validate the attested "current content" of a file ------
|
|
115
|
-
// A head is { text, hash, at, by?, receipt }. It is internally valid iff its receipt
|
|
116
|
-
// is a genuine signed provenance record AND it actually attests THIS text (hash ties
|
|
117
|
-
// the receipt to the content). This is the structural half of content authority; the
|
|
118
|
-
// relay adds non-regression (contentHealth) on top so the current content of a file is
|
|
119
|
-
// always both verified AND no more broken than it was.
|
|
120
|
-
export function headOk(head) {
|
|
121
|
-
if (!head || !head.receipt || typeof head.text !== 'string') return { ok: false, reason: 'malformed head' }
|
|
122
|
-
const h = contentHash(head.text)
|
|
123
|
-
if (head.hash !== h) return { ok: false, reason: 'head.hash does not match head.text' }
|
|
124
|
-
if (head.receipt.contentHash !== h) return { ok: false, reason: 'receipt does not attest this text' }
|
|
125
|
-
const v = verifyReceipt(head.receipt)
|
|
126
|
-
if (!v.ok) return { ok: false, reason: v.reason }
|
|
127
|
-
return { ok: true }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- an empty medium ------------------------------------------------------------
|
|
131
|
-
export function emptyState() {
|
|
132
|
-
return { text: null, hash: null, history: [] } // history = the provenance DAG (append-only)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// --- publish: the one operation, enforcing all three invariants -----------------
|
|
136
|
-
// publish(state, change) -> { accepted, state, receipt, reason }
|
|
137
|
-
// accepted:false -> the change is REFUSED; `state` is returned UNCHANGED (the medium
|
|
138
|
-
// never regresses), `reason` says which invariant stopped it.
|
|
139
|
-
// accepted:true -> `state` is the new state; `receipt` is the DAG node just linked.
|
|
140
|
-
//
|
|
141
|
-
// The function is PURE: same (state, change) -> same result on every peer. It reads no
|
|
142
|
-
// clock and no randomness (timestamps live in the signed change), so a peer can replay
|
|
143
|
-
// a change log and reach byte-identical state — the operational meaning of "convergent".
|
|
144
|
-
export function publish(state, change) {
|
|
145
|
-
state = state || emptyState()
|
|
146
|
-
|
|
147
|
-
// I1 — PROVENANCE. Refuse anything we can't attribute to a verified author + intent.
|
|
148
|
-
const v = verifyProvenance(change)
|
|
149
|
-
if (!v.ok) return { accepted: false, state, reason: 'I1 provenance: ' + v.reason }
|
|
150
|
-
|
|
151
|
-
const filename = change.filename || 'x.js'
|
|
152
|
-
|
|
153
|
-
// GENESIS — first change into an empty medium. Accept iff it is itself healthy; there
|
|
154
|
-
// is nothing to merge against, so convergence/non-regression are trivially satisfied.
|
|
155
|
-
if (state.text == null) {
|
|
156
|
-
if (health(change.text, filename) > 0) return { accepted: false, state, reason: 'I3 non-regression: genesis change does not parse' }
|
|
157
|
-
return commit(state, change, change.text, filename, 'genesis')
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// FIXED POINT — the change reproduces the current state exactly. Nothing to do; the
|
|
161
|
-
// medium is already there. (Keeps re-publishes and echoes idempotent.)
|
|
162
|
-
if (change.text === state.text) return commit(state, change, state.text, filename, 'noop')
|
|
163
|
-
|
|
164
|
-
// I2 — CONVERGENCE via ICR's deterministic, symmetric 3-way merge:
|
|
165
|
-
// base = the ancestor the change was authored against; a = the medium's current
|
|
166
|
-
// text; b = the incoming change. ICR guarantees the merge is order-independent, so
|
|
167
|
-
// two peers merging the same changes converge on identical bytes.
|
|
168
|
-
const merged = structuralMerge(change.base, state.text, change.text, { filename, authors: { a: 'medium', b: change.prov.author } })
|
|
169
|
-
|
|
170
|
-
// I3 — NON-REGRESSION. Only 'auto' (a clean, parseable merge) may move the medium.
|
|
171
|
-
// 'semantic-conflict' — the change collides with the current state on the SAME unit;
|
|
172
|
-
// landing it would clobber another agent's edit. Refuse; the
|
|
173
|
-
// author must reconcile against the new parent and re-publish.
|
|
174
|
-
// 'fallback' — the merge would not parse / inputs unparseable. Refuse rather
|
|
175
|
-
// than let the shared state become more broken than it is.
|
|
176
|
-
if (merged.status !== 'auto') {
|
|
177
|
-
const reason = merged.status === 'semantic-conflict'
|
|
178
|
-
? `I3 non-regression: semantic conflict on ${(merged.conflicts || []).join(', ') || 'a shared unit'} — reconcile against parent ${state.hash} and re-publish`
|
|
179
|
-
: `I3 non-regression: merge would not produce healthy code (${merged.reason || 'fallback'})`
|
|
180
|
-
return { accepted: false, state, reason, conflicts: merged.conflicts || [] }
|
|
181
|
-
}
|
|
182
|
-
// Defense in depth: the merged text must be at least as healthy as the current state.
|
|
183
|
-
if (health(merged.text, filename) > health(state.text, filename))
|
|
184
|
-
return { accepted: false, state, reason: 'I3 non-regression: merged result parses worse than current state' }
|
|
185
|
-
|
|
186
|
-
return commit(state, change, merged.text, filename, 'merge', merged)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Link an accepted change into the provenance DAG and advance the state. The receipt is
|
|
190
|
-
// the audit record: it records the author/intent/parent from the signed change, the hash
|
|
191
|
-
// the medium actually arrived at, and how (genesis | noop | merge). The chain of receipts
|
|
192
|
-
// is independently verifiable end-to-end via verifyChain().
|
|
193
|
-
function commit(state, change, newText, filename, how, merged) {
|
|
194
|
-
const receipt = {
|
|
195
|
-
author: change.prov.author,
|
|
196
|
-
intent: change.prov.intent,
|
|
197
|
-
parent: change.prov.parent, // what the author built on
|
|
198
|
-
prevHash: state.hash || GENESIS, // the medium's state just before this commit
|
|
199
|
-
contentHash: change.prov.contentHash,
|
|
200
|
-
resultHash: contentHash(newText), // the hash the medium arrived at
|
|
201
|
-
at: change.prov.at,
|
|
202
|
-
sig: change.prov.sig,
|
|
203
|
-
pk: change.prov.pk,
|
|
204
|
-
how,
|
|
205
|
-
...(merged && merged.renames && merged.renames.length ? { renames: merged.renames } : {}),
|
|
206
|
-
...(merged && merged.provenance ? { units: merged.provenance } : {}),
|
|
207
|
-
}
|
|
208
|
-
return {
|
|
209
|
-
accepted: true,
|
|
210
|
-
receipt,
|
|
211
|
-
state: { text: newText, hash: receipt.resultHash, history: [...state.history, receipt] },
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// --- audit: verify ONE receipt in isolation -------------------------------------
|
|
216
|
-
// A receipt is a flattened, self-contained provenance record (as stored in the live
|
|
217
|
-
// shared ledger). This proves its integrity WITHOUT the patch text: the signature
|
|
218
|
-
// commits to (author, parent, intent, contentHash, at), so a valid signature means
|
|
219
|
-
// "this verified author vouched for a change producing state <contentHash> with this
|
|
220
|
-
// intent at this time". Used to audit the live ledger, where concurrent writers make
|
|
221
|
-
// the history a signed DAG rather than a single line (so verifyChain's linear check
|
|
222
|
-
// doesn't apply, but per-receipt provenance still does — every entry is attributable).
|
|
223
|
-
export function verifyReceipt(r) {
|
|
224
|
-
if (!r || !r.pk || !r.author || !r.sig || !r.contentHash) return { ok: false, reason: 'missing provenance fields' }
|
|
225
|
-
if (keyFingerprint(r.pk) !== r.author) return { ok: false, reason: 'author id does not match public key' }
|
|
226
|
-
const msg = signingMessage({ author: r.author, parent: r.parent, intent: r.intent, contentHash: r.contentHash, at: r.at })
|
|
227
|
-
let valid = false
|
|
228
|
-
try { valid = crypto.verify(null, Buffer.from(msg), crypto.createPublicKey(r.pk), Buffer.from(r.sig, 'base64url')) } catch { valid = false }
|
|
229
|
-
return valid ? { ok: true } : { ok: false, reason: 'bad signature' }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// --- audit: verify an entire provenance DAG end-to-end --------------------------
|
|
233
|
-
// Re-checks every receipt's signature and identity, and that the chain is unbroken
|
|
234
|
-
// (each commit's prevHash equals the prior commit's resultHash). Returns
|
|
235
|
-
// { ok, length, broken? } — a full, independent audit of who authored every state the
|
|
236
|
-
// medium ever held. This is what makes "provenance-verified" true for HISTORY, not just
|
|
237
|
-
// the latest write: anyone can replay the chain and prove no state was ever unattributed.
|
|
238
|
-
export function verifyChain(history) {
|
|
239
|
-
if (!Array.isArray(history)) return { ok: false, reason: 'no history' }
|
|
240
|
-
let prev = GENESIS
|
|
241
|
-
for (let i = 0; i < history.length; i++) {
|
|
242
|
-
const r = history[i]
|
|
243
|
-
if (keyFingerprint(r.pk) !== r.author) return { ok: false, broken: i, reason: 'author id / key mismatch' }
|
|
244
|
-
const msg = signingMessage({ author: r.author, parent: r.parent, intent: r.intent, contentHash: r.contentHash, at: r.at })
|
|
245
|
-
let valid = false
|
|
246
|
-
try { valid = crypto.verify(null, Buffer.from(msg), crypto.createPublicKey(r.pk), Buffer.from(r.sig, 'base64url')) } catch { valid = false }
|
|
247
|
-
if (!valid) return { ok: false, broken: i, reason: 'bad signature' }
|
|
248
|
-
if (r.prevHash !== prev) return { ok: false, broken: i, reason: `chain break: prevHash ${r.prevHash} != ${prev}` }
|
|
249
|
-
prev = r.resultHash
|
|
250
|
-
}
|
|
251
|
-
return { ok: true, length: history.length }
|
|
252
|
-
}
|
|
1
|
+
// substrate.js — the Hivecode convergent semantic substrate.
|
|
2
|
+
//
|
|
3
|
+
// This is the primitive the whole project has been building toward. It is NOT a
|
|
4
|
+
// connection protocol (that is MCP / A2A — a wire that carries messages). It is a
|
|
5
|
+
// MEDIUM: a shared state that agents write into, where the medium itself enforces
|
|
6
|
+
// guarantees no channel can. Coordination is a property of the medium, not of a
|
|
7
|
+
// controller.
|
|
8
|
+
//
|
|
9
|
+
// The unit of exchange is not a message or a tool call — it is a CHANGE: a triple
|
|
10
|
+
// (intent, patch, provenance)
|
|
11
|
+
// published against the medium's current state. On every publish the medium enforces
|
|
12
|
+
// three invariants, and refuses the change if any fails:
|
|
13
|
+
//
|
|
14
|
+
// I1 PROVENANCE-VERIFIED — every accepted byte traces to a cryptographically
|
|
15
|
+
// verified author and a DECLARED intent, chained to the state it built on.
|
|
16
|
+
// A forged, unsigned, or mis-attributed change is rejected. (This is the new
|
|
17
|
+
// part — no CRDT, no git, no MCP carries verifiable per-change provenance.)
|
|
18
|
+
//
|
|
19
|
+
// I2 CONVERGENT — merging is deterministic and symmetric (ICR guarantees this),
|
|
20
|
+
// so any number of agents applying the same set of changes in ANY order reach
|
|
21
|
+
// the exact same state. No divergence, no "last writer wins" corruption.
|
|
22
|
+
//
|
|
23
|
+
// I3 NON-REGRESSING — the medium only ever moves to an equal-or-healthier state.
|
|
24
|
+
// A change that would make the code parse worse than it already does (or that
|
|
25
|
+
// silently clobbers another agent's edit) is refused, not landed. The shared
|
|
26
|
+
// state can never become more broken than it was.
|
|
27
|
+
//
|
|
28
|
+
// Everything language-specific (parse, structural merge, intent detection) is
|
|
29
|
+
// delegated to ICR (icr.js). Everything identity-specific (self-certifying keys,
|
|
30
|
+
// fingerprints) reuses the crypto model already proven in token.js. This file adds
|
|
31
|
+
// only the missing layer: provenance as a first-class, verifiable, chained TYPE on
|
|
32
|
+
// every change — the thing that turns "merge + coordinate" into a governed medium.
|
|
33
|
+
|
|
34
|
+
import crypto from 'crypto'
|
|
35
|
+
import { structuralMerge, supports, parses, languageFor } from './icr.js'
|
|
36
|
+
import { keyFingerprint } from './token.js'
|
|
37
|
+
|
|
38
|
+
// --- hashing / canonical encoding ----------------------------------------------
|
|
39
|
+
// A content hash names a state. Two identical texts hash identically on every peer,
|
|
40
|
+
// so a hash is a stable, portable pointer into the provenance DAG.
|
|
41
|
+
const sha = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('base64url')
|
|
42
|
+
|
|
43
|
+
export const contentHash = (text) => sha(text == null ? '' : text)
|
|
44
|
+
export const GENESIS = 'genesis' // the parent of the very first change into an empty medium
|
|
45
|
+
|
|
46
|
+
// The exact bytes a change's signature commits to. Fixed field order (NOT JSON, whose
|
|
47
|
+
// key order is not guaranteed) so the signed message is identical on every peer. The
|
|
48
|
+
// signature binds WHO (author) + on-top-of-WHAT (parent) + WHY (intent) + WHAT
|
|
49
|
+
// (contentHash of the patch text) + WHEN (at). Change any one and the signature breaks.
|
|
50
|
+
function signingMessage(p) {
|
|
51
|
+
return [p.author, p.parent, p.intent || '', p.contentHash, p.at].join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- identity: self-certifying keypairs (Ed25519) ------------------------------
|
|
55
|
+
// An author IS the fingerprint of their public key (same anchoring token.js uses for
|
|
56
|
+
// secured rooms). You cannot claim to be another agent's id without their private key,
|
|
57
|
+
// and no key registry is needed — the key travels with the change and certifies itself.
|
|
58
|
+
export function genIdentity(name = '') {
|
|
59
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
60
|
+
const pk = publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
|
61
|
+
const sk = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
|
62
|
+
return { name, pk, sk, id: keyFingerprint(pk) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- authoring: turn a raw edit into a signed, provenance-typed CHANGE ----------
|
|
66
|
+
// `identity` is {pk, sk, id} from genIdentity (or a wrapped key). `base` is the text
|
|
67
|
+
// the agent edited FROM (its known ancestor); `text` is the result. `intent` is the
|
|
68
|
+
// declared reason (carried, verified, and used by ICR's semantic merge).
|
|
69
|
+
export function authorChange({ identity, filename, base, text, intent = '', at }) {
|
|
70
|
+
if (at == null) throw new Error('authorChange: `at` is required (pass a timestamp; the substrate never reads the clock itself so it stays deterministic/replayable)')
|
|
71
|
+
const cHash = contentHash(text)
|
|
72
|
+
const prov = {
|
|
73
|
+
author: identity.id,
|
|
74
|
+
pk: identity.pk,
|
|
75
|
+
parent: contentHash(base), // the state this change was made against
|
|
76
|
+
intent: String(intent || ''),
|
|
77
|
+
contentHash: cHash,
|
|
78
|
+
at,
|
|
79
|
+
}
|
|
80
|
+
const sig = crypto.sign(null, Buffer.from(signingMessage(prov)), crypto.createPrivateKey(identity.sk))
|
|
81
|
+
prov.sig = sig.toString('base64url')
|
|
82
|
+
return { filename, base, text, intent: prov.intent, prov }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- I1: verify a change's provenance ------------------------------------------
|
|
86
|
+
// Returns { ok, reason }. Checks, in order: the signature is valid for the carried
|
|
87
|
+
// public key; the author id is the fingerprint of that key (no impersonation); and
|
|
88
|
+
// the signed contentHash actually matches the patch text (no swapping the body after
|
|
89
|
+
// signing). A change that fails any of these is not trusted and is never applied.
|
|
90
|
+
export function verifyProvenance(change) {
|
|
91
|
+
const p = change && change.prov
|
|
92
|
+
if (!p || !p.author || !p.pk || !p.sig || !p.contentHash) return { ok: false, reason: 'missing provenance' }
|
|
93
|
+
if (keyFingerprint(p.pk) !== p.author) return { ok: false, reason: 'author id does not match public key (impersonation)' }
|
|
94
|
+
if (contentHash(change.text) !== p.contentHash) return { ok: false, reason: 'content hash does not match text (tampered body)' }
|
|
95
|
+
let valid = false
|
|
96
|
+
try { valid = crypto.verify(null, Buffer.from(signingMessage(p)), crypto.createPublicKey(p.pk), Buffer.from(p.sig, 'base64url')) }
|
|
97
|
+
catch { valid = false }
|
|
98
|
+
if (!valid) return { ok: false, reason: 'bad signature (forged or altered provenance)' }
|
|
99
|
+
return { ok: true }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- parse health: the metric non-regression is defined against -----------------
|
|
103
|
+
// 0 = parses; 1 = does not. (A coarse but honest, language-agnostic health score:
|
|
104
|
+
// the medium refuses to move from a parsing state to a non-parsing one.) For an
|
|
105
|
+
// unsupported language ICR can't judge structure, so we treat it as always-healthy
|
|
106
|
+
// and fall back to last-writer semantics — provenance still applies.
|
|
107
|
+
function health(text, filename) {
|
|
108
|
+
if (!supports(filename)) return 0
|
|
109
|
+
return parses(text, languageFor(filename)) ? 0 : 1
|
|
110
|
+
}
|
|
111
|
+
// Exported for the relay's content-authority check (non-regression on the head).
|
|
112
|
+
export function contentHealth(text, filename) { return health(text, filename) }
|
|
113
|
+
|
|
114
|
+
// --- authoritative head: validate the attested "current content" of a file ------
|
|
115
|
+
// A head is { text, hash, at, by?, receipt }. It is internally valid iff its receipt
|
|
116
|
+
// is a genuine signed provenance record AND it actually attests THIS text (hash ties
|
|
117
|
+
// the receipt to the content). This is the structural half of content authority; the
|
|
118
|
+
// relay adds non-regression (contentHealth) on top so the current content of a file is
|
|
119
|
+
// always both verified AND no more broken than it was.
|
|
120
|
+
export function headOk(head) {
|
|
121
|
+
if (!head || !head.receipt || typeof head.text !== 'string') return { ok: false, reason: 'malformed head' }
|
|
122
|
+
const h = contentHash(head.text)
|
|
123
|
+
if (head.hash !== h) return { ok: false, reason: 'head.hash does not match head.text' }
|
|
124
|
+
if (head.receipt.contentHash !== h) return { ok: false, reason: 'receipt does not attest this text' }
|
|
125
|
+
const v = verifyReceipt(head.receipt)
|
|
126
|
+
if (!v.ok) return { ok: false, reason: v.reason }
|
|
127
|
+
return { ok: true }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- an empty medium ------------------------------------------------------------
|
|
131
|
+
export function emptyState() {
|
|
132
|
+
return { text: null, hash: null, history: [] } // history = the provenance DAG (append-only)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- publish: the one operation, enforcing all three invariants -----------------
|
|
136
|
+
// publish(state, change) -> { accepted, state, receipt, reason }
|
|
137
|
+
// accepted:false -> the change is REFUSED; `state` is returned UNCHANGED (the medium
|
|
138
|
+
// never regresses), `reason` says which invariant stopped it.
|
|
139
|
+
// accepted:true -> `state` is the new state; `receipt` is the DAG node just linked.
|
|
140
|
+
//
|
|
141
|
+
// The function is PURE: same (state, change) -> same result on every peer. It reads no
|
|
142
|
+
// clock and no randomness (timestamps live in the signed change), so a peer can replay
|
|
143
|
+
// a change log and reach byte-identical state — the operational meaning of "convergent".
|
|
144
|
+
export function publish(state, change) {
|
|
145
|
+
state = state || emptyState()
|
|
146
|
+
|
|
147
|
+
// I1 — PROVENANCE. Refuse anything we can't attribute to a verified author + intent.
|
|
148
|
+
const v = verifyProvenance(change)
|
|
149
|
+
if (!v.ok) return { accepted: false, state, reason: 'I1 provenance: ' + v.reason }
|
|
150
|
+
|
|
151
|
+
const filename = change.filename || 'x.js'
|
|
152
|
+
|
|
153
|
+
// GENESIS — first change into an empty medium. Accept iff it is itself healthy; there
|
|
154
|
+
// is nothing to merge against, so convergence/non-regression are trivially satisfied.
|
|
155
|
+
if (state.text == null) {
|
|
156
|
+
if (health(change.text, filename) > 0) return { accepted: false, state, reason: 'I3 non-regression: genesis change does not parse' }
|
|
157
|
+
return commit(state, change, change.text, filename, 'genesis')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// FIXED POINT — the change reproduces the current state exactly. Nothing to do; the
|
|
161
|
+
// medium is already there. (Keeps re-publishes and echoes idempotent.)
|
|
162
|
+
if (change.text === state.text) return commit(state, change, state.text, filename, 'noop')
|
|
163
|
+
|
|
164
|
+
// I2 — CONVERGENCE via ICR's deterministic, symmetric 3-way merge:
|
|
165
|
+
// base = the ancestor the change was authored against; a = the medium's current
|
|
166
|
+
// text; b = the incoming change. ICR guarantees the merge is order-independent, so
|
|
167
|
+
// two peers merging the same changes converge on identical bytes.
|
|
168
|
+
const merged = structuralMerge(change.base, state.text, change.text, { filename, authors: { a: 'medium', b: change.prov.author } })
|
|
169
|
+
|
|
170
|
+
// I3 — NON-REGRESSION. Only 'auto' (a clean, parseable merge) may move the medium.
|
|
171
|
+
// 'semantic-conflict' — the change collides with the current state on the SAME unit;
|
|
172
|
+
// landing it would clobber another agent's edit. Refuse; the
|
|
173
|
+
// author must reconcile against the new parent and re-publish.
|
|
174
|
+
// 'fallback' — the merge would not parse / inputs unparseable. Refuse rather
|
|
175
|
+
// than let the shared state become more broken than it is.
|
|
176
|
+
if (merged.status !== 'auto') {
|
|
177
|
+
const reason = merged.status === 'semantic-conflict'
|
|
178
|
+
? `I3 non-regression: semantic conflict on ${(merged.conflicts || []).join(', ') || 'a shared unit'} — reconcile against parent ${state.hash} and re-publish`
|
|
179
|
+
: `I3 non-regression: merge would not produce healthy code (${merged.reason || 'fallback'})`
|
|
180
|
+
return { accepted: false, state, reason, conflicts: merged.conflicts || [] }
|
|
181
|
+
}
|
|
182
|
+
// Defense in depth: the merged text must be at least as healthy as the current state.
|
|
183
|
+
if (health(merged.text, filename) > health(state.text, filename))
|
|
184
|
+
return { accepted: false, state, reason: 'I3 non-regression: merged result parses worse than current state' }
|
|
185
|
+
|
|
186
|
+
return commit(state, change, merged.text, filename, 'merge', merged)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Link an accepted change into the provenance DAG and advance the state. The receipt is
|
|
190
|
+
// the audit record: it records the author/intent/parent from the signed change, the hash
|
|
191
|
+
// the medium actually arrived at, and how (genesis | noop | merge). The chain of receipts
|
|
192
|
+
// is independently verifiable end-to-end via verifyChain().
|
|
193
|
+
function commit(state, change, newText, filename, how, merged) {
|
|
194
|
+
const receipt = {
|
|
195
|
+
author: change.prov.author,
|
|
196
|
+
intent: change.prov.intent,
|
|
197
|
+
parent: change.prov.parent, // what the author built on
|
|
198
|
+
prevHash: state.hash || GENESIS, // the medium's state just before this commit
|
|
199
|
+
contentHash: change.prov.contentHash,
|
|
200
|
+
resultHash: contentHash(newText), // the hash the medium arrived at
|
|
201
|
+
at: change.prov.at,
|
|
202
|
+
sig: change.prov.sig,
|
|
203
|
+
pk: change.prov.pk,
|
|
204
|
+
how,
|
|
205
|
+
...(merged && merged.renames && merged.renames.length ? { renames: merged.renames } : {}),
|
|
206
|
+
...(merged && merged.provenance ? { units: merged.provenance } : {}),
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
accepted: true,
|
|
210
|
+
receipt,
|
|
211
|
+
state: { text: newText, hash: receipt.resultHash, history: [...state.history, receipt] },
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- audit: verify ONE receipt in isolation -------------------------------------
|
|
216
|
+
// A receipt is a flattened, self-contained provenance record (as stored in the live
|
|
217
|
+
// shared ledger). This proves its integrity WITHOUT the patch text: the signature
|
|
218
|
+
// commits to (author, parent, intent, contentHash, at), so a valid signature means
|
|
219
|
+
// "this verified author vouched for a change producing state <contentHash> with this
|
|
220
|
+
// intent at this time". Used to audit the live ledger, where concurrent writers make
|
|
221
|
+
// the history a signed DAG rather than a single line (so verifyChain's linear check
|
|
222
|
+
// doesn't apply, but per-receipt provenance still does — every entry is attributable).
|
|
223
|
+
export function verifyReceipt(r) {
|
|
224
|
+
if (!r || !r.pk || !r.author || !r.sig || !r.contentHash) return { ok: false, reason: 'missing provenance fields' }
|
|
225
|
+
if (keyFingerprint(r.pk) !== r.author) return { ok: false, reason: 'author id does not match public key' }
|
|
226
|
+
const msg = signingMessage({ author: r.author, parent: r.parent, intent: r.intent, contentHash: r.contentHash, at: r.at })
|
|
227
|
+
let valid = false
|
|
228
|
+
try { valid = crypto.verify(null, Buffer.from(msg), crypto.createPublicKey(r.pk), Buffer.from(r.sig, 'base64url')) } catch { valid = false }
|
|
229
|
+
return valid ? { ok: true } : { ok: false, reason: 'bad signature' }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- audit: verify an entire provenance DAG end-to-end --------------------------
|
|
233
|
+
// Re-checks every receipt's signature and identity, and that the chain is unbroken
|
|
234
|
+
// (each commit's prevHash equals the prior commit's resultHash). Returns
|
|
235
|
+
// { ok, length, broken? } — a full, independent audit of who authored every state the
|
|
236
|
+
// medium ever held. This is what makes "provenance-verified" true for HISTORY, not just
|
|
237
|
+
// the latest write: anyone can replay the chain and prove no state was ever unattributed.
|
|
238
|
+
export function verifyChain(history) {
|
|
239
|
+
if (!Array.isArray(history)) return { ok: false, reason: 'no history' }
|
|
240
|
+
let prev = GENESIS
|
|
241
|
+
for (let i = 0; i < history.length; i++) {
|
|
242
|
+
const r = history[i]
|
|
243
|
+
if (keyFingerprint(r.pk) !== r.author) return { ok: false, broken: i, reason: 'author id / key mismatch' }
|
|
244
|
+
const msg = signingMessage({ author: r.author, parent: r.parent, intent: r.intent, contentHash: r.contentHash, at: r.at })
|
|
245
|
+
let valid = false
|
|
246
|
+
try { valid = crypto.verify(null, Buffer.from(msg), crypto.createPublicKey(r.pk), Buffer.from(r.sig, 'base64url')) } catch { valid = false }
|
|
247
|
+
if (!valid) return { ok: false, broken: i, reason: 'bad signature' }
|
|
248
|
+
if (r.prevHash !== prev) return { ok: false, broken: i, reason: `chain break: prevHash ${r.prevHash} != ${prev}` }
|
|
249
|
+
prev = r.resultHash
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, length: history.length }
|
|
252
|
+
}
|