@heyquilt/seam 0.26.0 → 0.130.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 (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +623 -69
  11. package/lib/quilts/client.js +307 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +412 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -0,0 +1,351 @@
1
+ // Snapshots as prolly trees (plans/quilt-source-control/03, "Prolly-tree snapshots"): the full
2
+ // tree of a commit, over the quilt's sorted path list. Node boundaries come from a hash of the
3
+ // key bytes only, so the shape depends on the paths and nothing else: two commits with the same
4
+ // files have the same root, and one changed file rewrites a handful of nodes.
5
+ //
6
+ // Leaf node: { l: 0, e: [[path, mode, blob, size] | [path, 'conflict', term, 0]] }
7
+ // Internal node: { l: level, e: [[first_key, child_id]] }
8
+ // Node id: blake3("quilt tree v1", canonical CBOR of the node).
9
+ import { encode, decode } from './cbor.js'
10
+ import { idOf } from './hash.js'
11
+ import { cmpPath, under } from './records.js'
12
+
13
+ // Boundaries: after at least MIN entries, a node ends after an entry whose key hash has its low
14
+ // six bits set (a mean near 64 entries, about 4 KiB of leaf); never more than MAX entries.
15
+ const MIN = 8
16
+ const MAX = 256
17
+ const MASK = 63
18
+
19
+ const hex = (b) => Buffer.from(b).toString('hex')
20
+
21
+ /** FNV-1a over the key's bytes and the level, the boundary hash ("quilt tree v1"). */
22
+ function keyHash (key, level) {
23
+ let h = 0x811c9dc5 ^ level
24
+ const b = Buffer.from(key)
25
+ for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193) >>> 0 }
26
+ return h >>> 0
27
+ }
28
+
29
+ const leafRow = (e) => e.mode === 'conflict'
30
+ ? [e.path, 'conflict', Buffer.from(e.term, 'hex'), 0]
31
+ : [e.path, e.mode, Buffer.from(e.blob, 'hex'), e.size]
32
+ const unleaf = ([path, mode, id, size]) => mode === 'conflict'
33
+ ? { path, mode: 'conflict', term: hex(id) }
34
+ : { path, mode, blob: hex(id), size }
35
+
36
+ /** A node's canonical bytes and id. */
37
+ function nodeOf (level, rows) {
38
+ const bytes = encode({ l: level, e: rows })
39
+ return { id: idOf('tree', bytes), bytes }
40
+ }
41
+
42
+ /** Decodes a tree node: { level, entries } (leaf entries, or [{ key, child }]). */
43
+ export function decodeNode (bytes) {
44
+ const n = decode(bytes)
45
+ if (n.l === 0) return { level: 0, entries: n.e.map(unleaf) }
46
+ return { level: n.l, entries: n.e.map(([key, child]) => ({ key, child: hex(child) })) }
47
+ }
48
+
49
+ function chunk (items, keyOf, level) {
50
+ const groups = []
51
+ let cur = []
52
+ for (const it of items) {
53
+ cur.push(it)
54
+ const k = keyOf(it)
55
+ if (cur.length >= MAX || (cur.length >= MIN && (keyHash(k, level) & MASK) === MASK)) { groups.push(cur); cur = [] }
56
+ }
57
+ if (cur.length || !groups.length) groups.push(cur)
58
+ return groups
59
+ }
60
+
61
+ /**
62
+ * Builds the tree of `entries` (sorted by path, unique). Returns { root, nodes } where nodes is a
63
+ * Map of id → bytes for every node of the tree (callers store the ones they don't have).
64
+ */
65
+ export function buildTree (entries) {
66
+ const nodes = new Map()
67
+ let level = 0
68
+ let items = chunk(entries, (e) => e.path, 0).map((g) => {
69
+ const n = nodeOf(0, g.map(leafRow))
70
+ nodes.set(n.id, n.bytes)
71
+ return { key: g.length ? g[0].path : '', child: n.id }
72
+ })
73
+ while (items.length > 1) {
74
+ level++
75
+ items = chunk(items, (it) => it.key, level).map((g) => {
76
+ const n = nodeOf(level, g.map((it) => [it.key, Buffer.from(it.child, 'hex')]))
77
+ nodes.set(n.id, n.bytes)
78
+ return { key: g[0].key, child: n.id }
79
+ })
80
+ }
81
+ return { root: items[0].child, nodes }
82
+ }
83
+
84
+ /** The empty tree: never stored, known to every reader. */
85
+ const empty = buildTree([])
86
+ export const EMPTY_TREE = empty.root
87
+ export const EMPTY_TREE_BYTES = empty.nodes.get(empty.root)
88
+
89
+ /** Applies changes ({ path, op, blob, mode, size, term }) to sorted entries; returns new sorted entries. */
90
+ export function applyChanges (entries, changes) {
91
+ const map = new Map(entries.map((e) => [e.path, e]))
92
+ for (const c of changes) {
93
+ if (c.op === 'delete') map.delete(c.path)
94
+ else if (c.op === 'conflict') map.set(c.path, { path: c.path, mode: 'conflict', term: c.term })
95
+ else map.set(c.path, { path: c.path, mode: c.mode, blob: c.blob, size: c.size })
96
+ }
97
+ return [...map.values()].sort((a, b) => cmpPath(a.path, b.path))
98
+ }
99
+
100
+ /**
101
+ * Reads trees through `getNode(id) → bytes` (async; callers cache). Every read is a walk from a
102
+ * root, skipping the subtrees it does not need.
103
+ */
104
+ export class TreeReader {
105
+ constructor (getNode) {
106
+ this.getNode = getNode
107
+ this.cache = new Map()
108
+ }
109
+
110
+ async node (id) {
111
+ let n = this.cache.get(id)
112
+ if (!n) {
113
+ n = decodeNode(id === EMPTY_TREE ? EMPTY_TREE_BYTES : await this.getNode(id))
114
+ this.cache.set(id, n)
115
+ if (this.cache.size > 20000) this.cache.delete(this.cache.keys().next().value)
116
+ }
117
+ return n
118
+ }
119
+
120
+ /** Every entry of the tree, in order. */
121
+ async entries (root) { return this.list(root, ['']) }
122
+
123
+ /** The entry at `path`, or null. */
124
+ async lookup (root, path) {
125
+ const found = await this.list(root, [path], { exact: true })
126
+ return found.find((e) => e.path === path) || null
127
+ }
128
+
129
+ /**
130
+ * The entries under any of `prefixes` (directories; '' is the root), in order. Only the nodes
131
+ * whose key range meets a prefix are read.
132
+ */
133
+ async list (root, prefixes, { exact = false } = {}) {
134
+ const ranges = prefixes.map((p) => p ? (exact ? [[p, p + '\0']] : [[p, p + '\0'], [p + '/', p + '0']]) : [['', null]]).flat()
135
+ const meets = (lo, hi) => ranges.some(([a, b]) => (b === null || cmpPath(lo, b) < 0) && (hi === null || cmpPath(hi, a) > 0))
136
+ const out = []
137
+ const walk = async (id, lo, hi) => {
138
+ const n = await this.node(id)
139
+ if (n.level === 0) {
140
+ for (const e of n.entries) if (prefixes.some((p) => exact ? e.path === p : under(e.path, p))) out.push(e)
141
+ return
142
+ }
143
+ for (let i = 0; i < n.entries.length; i++) {
144
+ const clo = i === 0 ? lo : n.entries[i].key
145
+ const chi = i + 1 < n.entries.length ? n.entries[i + 1].key : hi
146
+ if (meets(clo, chi)) await walk(n.entries[i].child, clo, chi)
147
+ }
148
+ }
149
+ await walk(root, '', null)
150
+ return out
151
+ }
152
+
153
+ /**
154
+ * What changed from tree `a` to tree `b`: [{ path, before?, after? }] in path order. Subtrees
155
+ * with the same id on both sides are never read, so the cost follows the change, not the tree.
156
+ */
157
+ async diff (a, b) {
158
+ if (a === b) return []
159
+ let fa = new Set([a])
160
+ let fb = new Set([b])
161
+ for (;;) {
162
+ for (const id of [...fa]) if (fb.has(id)) { fa.delete(id); fb.delete(id) }
163
+ const expand = async (front) => {
164
+ const next = new Set()
165
+ let grew = false
166
+ for (const id of front) {
167
+ const n = await this.node(id)
168
+ if (n.level === 0) next.add(id)
169
+ else { grew = true; for (const e of n.entries) next.add(e.child) }
170
+ }
171
+ return { next, grew }
172
+ }
173
+ const [ea, eb] = [await expand(fa), await expand(fb)]
174
+ fa = ea.next; fb = eb.next
175
+ if (!ea.grew && !eb.grew) break
176
+ }
177
+ for (const id of [...fa]) if (fb.has(id)) { fa.delete(id); fb.delete(id) }
178
+ const leaves = async (front) => {
179
+ const m = new Map()
180
+ for (const id of front) for (const e of (await this.node(id)).entries) m.set(e.path, e)
181
+ return m
182
+ }
183
+ const [ma, mb] = [await leaves(fa), await leaves(fb)]
184
+ const paths = [...new Set([...ma.keys(), ...mb.keys()])].sort(cmpPath)
185
+ const out = []
186
+ for (const p of paths) {
187
+ const x = ma.get(p)
188
+ const y = mb.get(p)
189
+ if (x && y && sameEntry(x, y)) continue
190
+ out.push({ path: p, ...(x ? { before: x } : {}), ...(y ? { after: y } : {}) })
191
+ }
192
+ return out
193
+ }
194
+ }
195
+
196
+ export const sameEntry = (x, y) => x.mode === y.mode && x.blob === y.blob && x.term === y.term
197
+
198
+ const itemKey = (level) => level === 0 ? (e) => e.path : (it) => it.key
199
+ const nodeRows = (level, items) => level === 0 ? items.map(leafRow) : items.map((it) => [it.key, Buffer.from(it.child, 'hex')])
200
+
201
+ /**
202
+ * A cursor over the node references of one level of the tree at `root` (top level `top`),
203
+ * placed on the node whose subtree holds `key`. It reads only the nodes on its way down and
204
+ * the neighbours it is moved onto: never a whole level.
205
+ */
206
+ async function cursorAt (reader, root, top, level, key) {
207
+ const stack = []
208
+ let id = root
209
+ for (let l = top; l > level; l--) {
210
+ const es = (await reader.node(id)).entries
211
+ let lo = 0; let hi = es.length - 1
212
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (cmpPath(es[mid].key, key) <= 0) lo = mid; else hi = mid - 1 }
213
+ stack.push({ es, i: lo })
214
+ id = es[lo].child
215
+ }
216
+ let end = false
217
+ return {
218
+ get end () { return end },
219
+ ref: () => end ? null : stack.length ? stack[stack.length - 1].es[stack[stack.length - 1].i] : { key: '', child: root },
220
+ first: () => stack.every((x) => x.i === 0),
221
+ async next () {
222
+ let d = stack.length - 1
223
+ while (d >= 0 && stack[d].i + 1 >= stack[d].es.length) d--
224
+ if (d < 0) { end = true; return }
225
+ stack[d].i++
226
+ for (let j = d + 1; j < stack.length; j++) stack[j] = { es: (await reader.node(stack[j - 1].es[stack[j - 1].i].child)).entries, i: 0 }
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Re-chunks one level where it was edited. `edits` ([{ from, to, items }], in order) replace the
233
+ * items of the old nodes from..to. Chunking restarts at an edit's first node (its start is a
234
+ * boundary in the old tree, so the chunker's state is the same) and stops once a new boundary
235
+ * falls on an old node's end with no edit next: from there the old nodes are the new ones.
236
+ * Returns splices [{ from, to, refs }]: old nodes from..to became `refs`.
237
+ */
238
+ async function rechunkLevel (reader, root, top, level, edits, nodes) {
239
+ const keyOf = itemKey(level)
240
+ const splices = []
241
+ let e = 0
242
+ while (e < edits.length) {
243
+ const cur = await cursorAt(reader, root, top, level, edits[e].from.key)
244
+ const atFirst = cur.first()
245
+ const sp = { from: cur.ref(), to: null, refs: [] }
246
+ let buf = []
247
+ const emit = () => {
248
+ const n = nodeOf(level, nodeRows(level, buf))
249
+ nodes.set(n.id, n.bytes)
250
+ sp.refs.push({ key: buf.length ? keyOf(buf[0]) : '', child: n.id })
251
+ buf = []
252
+ }
253
+ let synced = false
254
+ for (;;) {
255
+ let items
256
+ if (e < edits.length && cur.ref().child === edits[e].from.child) {
257
+ items = edits[e].items
258
+ while (cur.ref().child !== edits[e].to.child) await cur.next()
259
+ e++
260
+ } else items = (await reader.node(cur.ref().child)).entries
261
+ sp.to = cur.ref()
262
+ let atEnd = false
263
+ for (let k = 0; k < items.length; k++) {
264
+ buf.push(items[k])
265
+ if (buf.length >= MAX || (buf.length >= MIN && (keyHash(keyOf(items[k]), level) & MASK) === MASK)) { emit(); atEnd = k === items.length - 1 }
266
+ }
267
+ await cur.next()
268
+ if (cur.end) break
269
+ if (atEnd && !(e < edits.length && cur.ref().child === edits[e].from.child)) { synced = true; break }
270
+ }
271
+ if (!synced && (buf.length || (atFirst && !sp.refs.length))) emit()
272
+ splices.push(sp)
273
+ }
274
+ return splices
275
+ }
276
+
277
+ /** The level above's edits for `splices`: each parent holding a splice gets its new child list. */
278
+ async function parentEdits (reader, root, top, level, splices) {
279
+ const out = []
280
+ for (const sp of splices) {
281
+ const pF = (await cursorAt(reader, root, top, level, sp.from.key)).ref()
282
+ const pL = (await cursorAt(reader, root, top, level, sp.to.key)).ref()
283
+ const last = out[out.length - 1]
284
+ if (last && last.to.child === pF.child) { last.to = pL; last.splices.push(sp) } else out.push({ from: pF, to: pL, splices: [sp] })
285
+ }
286
+ for (const ed of out) {
287
+ const c = await cursorAt(reader, root, top, level, ed.from.key)
288
+ const items = []
289
+ let s = 0
290
+ let skipTo = null
291
+ for (;;) {
292
+ for (const ch of (await reader.node(c.ref().child)).entries) {
293
+ if (skipTo) { if (ch.child === skipTo) skipTo = null; continue }
294
+ const sp = ed.splices[s]
295
+ if (sp && ch.child === sp.from.child) { items.push(...sp.refs); if (ch.child !== sp.to.child) skipTo = sp.to.child; s++; continue }
296
+ items.push(ch)
297
+ }
298
+ if (c.ref().child === ed.to.child) break
299
+ await c.next()
300
+ }
301
+ ed.items = items
302
+ }
303
+ return out
304
+ }
305
+
306
+ /**
307
+ * The tree `root` with `changes` applied ({ path, op: put|delete|conflict, ... }), equal to
308
+ * `buildTree` of the changed entries but reading and writing only the nodes on the changed
309
+ * paths and the neighbours the chunker reads until it is back in step: O(changes × depth).
310
+ * Returns { root, nodes } (the new nodes).
311
+ */
312
+ export async function updateTree (reader, root, changes) {
313
+ const nodes = new Map()
314
+ if (!changes.length) return { root, nodes }
315
+ const top = (await reader.node(root)).level
316
+ // Level 0: the changes, grouped by the leaf they fall in.
317
+ let edits = []
318
+ for (const c of [...changes].sort((a, b) => cmpPath(a.path, b.path))) {
319
+ const ref = (await cursorAt(reader, root, top, 0, c.path)).ref()
320
+ const last = edits[edits.length - 1]
321
+ if (last && last.from.child === ref.child) last.changes.push(c)
322
+ else edits.push({ from: ref, to: ref, changes: [c] })
323
+ }
324
+ for (const ed of edits) ed.items = applyChanges((await reader.node(ed.from.child)).entries, ed.changes)
325
+ let refs
326
+ for (let l = 0; ; l++) {
327
+ const splices = await rechunkLevel(reader, root, top, l, edits, nodes)
328
+ if (l === top) { refs = splices[0].refs; break }
329
+ edits = await parentEdits(reader, root, top, l + 1, splices)
330
+ }
331
+ // The tree grew: chunk the levels above from memory, as buildTree does.
332
+ let level = top
333
+ while (refs.length > 1) {
334
+ level++
335
+ refs = chunk(refs, (it) => it.key, level).map((g) => {
336
+ const n = nodeOf(level, nodeRows(level, g))
337
+ nodes.set(n.id, n.bytes)
338
+ return { key: g[0].key, child: n.id }
339
+ })
340
+ }
341
+ // The tree shrank: a root with one child is that child (buildTree stops at a one-node level).
342
+ let r = refs[0].child
343
+ for (;;) {
344
+ const n = nodes.has(r) ? decodeNode(nodes.get(r)) : await reader.node(r)
345
+ if (n.level === 0 || n.entries.length !== 1) break
346
+ nodes.delete(r)
347
+ r = n.entries[0].child
348
+ }
349
+ if (r === EMPTY_TREE) nodes.delete(r)
350
+ return { root: r, nodes }
351
+ }
@@ -0,0 +1,51 @@
1
+ // The want-list answer's framing (04 "Object protocol: want-only fetch"): every found object's
2
+ // bytes as they are, then a JSON trailer. No base64, so an answer is the size of what it carries.
3
+ //
4
+ // "SEAMOBJ1" 8 bytes
5
+ // per object: kind (1 byte) · id (32 bytes) · length (u32 BE) · bytes
6
+ // kind 0 · length (u32 BE) · trailer JSON { missing, later?, redacted?, too_large? }
7
+ //
8
+ // Kinds: 1 blob, 2 tree, 3 commit, 4 term. `missing`: ids this caller cannot have (not there, or
9
+ // not theirs: the two are not told apart); `later`: ids left out because the answer was full,
10
+ // to ask for again.
11
+ const MAGIC = Buffer.from('SEAMOBJ1')
12
+ export const WANT_KINDS = ['blob', 'tree', 'commit', 'term']
13
+ export const WANT_MAX = 1000
14
+ export const WANT_CONTENT_TYPE = 'application/x-seam-objects'
15
+
16
+ export function encodeObjects (objects, trailer = {}) {
17
+ const parts = [MAGIC]
18
+ for (const o of objects) {
19
+ const head = Buffer.alloc(37)
20
+ head[0] = WANT_KINDS.indexOf(o.kind) + 1
21
+ Buffer.from(o.id, 'hex').copy(head, 1)
22
+ head.writeUInt32BE(o.bytes.length, 33)
23
+ parts.push(head, o.bytes)
24
+ }
25
+ const t = Buffer.from(JSON.stringify(trailer))
26
+ const end = Buffer.alloc(5)
27
+ end.writeUInt32BE(t.length, 1)
28
+ parts.push(end, t)
29
+ return Buffer.concat(parts)
30
+ }
31
+
32
+ export function decodeObjects (buf) {
33
+ buf = Buffer.from(buf)
34
+ if (buf.length < 13 || !buf.subarray(0, 8).equals(MAGIC)) throw new Error('not a want-list answer (SEAMOBJ1)')
35
+ const objects = []
36
+ let at = 8
37
+ for (;;) {
38
+ if (at + 5 > buf.length) throw new Error('a want-list answer ended early')
39
+ const kind = buf[at]
40
+ if (kind === 0) {
41
+ const n = buf.readUInt32BE(at + 1)
42
+ if (at + 5 + n !== buf.length) throw new Error('a want-list answer has a bad trailer')
43
+ return { objects, trailer: JSON.parse(buf.subarray(at + 5).toString('utf8')) }
44
+ }
45
+ if (!WANT_KINDS[kind - 1] || at + 37 > buf.length) throw new Error('a want-list answer has a bad frame')
46
+ const n = buf.readUInt32BE(at + 33)
47
+ if (at + 37 + n > buf.length) throw new Error('a want-list answer ended early')
48
+ objects.push({ kind: WANT_KINDS[kind - 1], id: buf.subarray(at + 1, at + 33).toString('hex'), bytes: buf.subarray(at + 37, at + 37 + n) })
49
+ at += 37 + n
50
+ }
51
+ }
@@ -0,0 +1,116 @@
1
+ // Seam's own webhooks: a member's subscription and its signed POSTs. Each POST carries
2
+ // x-quilt-event, x-quilt-delivery, x-quilt-timestamp and
3
+ // x-quilt-signature: sha256=HMAC-SHA256(secret, "<timestamp>.<body>"), and is retried after
4
+ // each of RETRY_DELAYS_MS when the receiver is down, slow, or answers 408, 429 or 5xx.
5
+ import crypto from 'node:crypto'
6
+ import net from 'node:net'
7
+
8
+ export const RETRY_DELAYS_MS = [1000, 5000, 25_000]
9
+ export const DELIVERY_TIMEOUT_MS = 10_000
10
+ export const MAX_URL = 2000
11
+ export const MIN_SECRET = 16
12
+ export const MAX_SECRET = 200
13
+ export const MAX_BEARER = 500
14
+
15
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
16
+ const PRIVATE_V4 = new net.BlockList()
17
+ for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE_V4.addSubnet(a, p, 'ipv4')
18
+ const PRIVATE_V6 = new net.BlockList()
19
+ for (const [a, p] of [['::', 96], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) PRIVATE_V6.addSubnet(a, p, 'ipv6')
20
+
21
+ /** Whether a host as written in a URL is this computer or a private network address. */
22
+ function isLocalHost (host) {
23
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '')
24
+ if (LOCAL_HOSTS.has(h) || h.endsWith('.localhost')) return true
25
+ const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
26
+ const a = mapped ? mapped[1] : h
27
+ const kind = net.isIP(a)
28
+ if (kind === 4) return PRIVATE_V4.check(a, 'ipv4')
29
+ if (kind === 6) return PRIVATE_V6.check(a, 'ipv6')
30
+ return false
31
+ }
32
+
33
+ /** The URL a subscription may POST to: https, no credentials, public (allowLocal also takes http to this computer). */
34
+ export function parseWebhookUrl (raw, { allowLocal = false } = {}) {
35
+ const s = String(raw || '').trim()
36
+ if (!s) throw new Error('give the URL seam should POST to')
37
+ if (s.length > MAX_URL) throw new Error('that URL is too long')
38
+ let u
39
+ try { u = new URL(s) } catch { throw new Error('that is not a valid URL') }
40
+ if (u.username || u.password) throw new Error('the URL may not carry a user name or password')
41
+ const local = isLocalHost(u.hostname)
42
+ if (u.protocol === 'http:') {
43
+ if (!allowLocal || !local) throw new Error('the URL must use https')
44
+ } else if (u.protocol !== 'https:') {
45
+ throw new Error('the URL must start with https://')
46
+ } else if (local && !allowLocal) {
47
+ throw new Error('the URL must be reachable from the internet, not a local or private address')
48
+ }
49
+ return u.toString()
50
+ }
51
+
52
+ /** { url, secret?, bearer? } → { url, secret, since, made, bearer? }: a secret left out is made (and shown once). */
53
+ export function makeSubscription ({ url, secret, bearer } = {}, { allowLocal = false, now = Date.now } = {}) {
54
+ const u = parseWebhookUrl(url, { allowLocal })
55
+ let s = secret == null ? '' : String(secret)
56
+ if (s && (s.length < MIN_SECRET || s.length > MAX_SECRET)) throw new Error(`the secret must be ${MIN_SECRET} to ${MAX_SECRET} characters (or leave it out and seam makes one)`)
57
+ const made = !s
58
+ if (made) s = crypto.randomBytes(24).toString('hex')
59
+ const b = bearer == null ? '' : String(bearer).trim()
60
+ if (b.length > MAX_BEARER || /[\r\n]/.test(b)) throw new Error(`the bearer key must be one line of at most ${MAX_BEARER} characters`)
61
+ return { url: u, secret: s, since: now(), made, ...(b ? { bearer: b } : {}) }
62
+ }
63
+
64
+ /** `sha256=<hex>`: HMAC-SHA256 with `secret` over `<ts>.<body>`. */
65
+ export function signWebhook (secret, ts, body) {
66
+ return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(`${ts}.${body}`).digest('hex')
67
+ }
68
+
69
+ /** For receivers: whether `signature` is right for this body and timestamp. */
70
+ export function verifyWebhook (secret, ts, body, signature) {
71
+ const want = Buffer.from(signWebhook(secret, ts, body))
72
+ const got = Buffer.from(String(signature || ''))
73
+ return want.length === got.length && crypto.timingSafeEqual(want, got)
74
+ }
75
+
76
+ const retryable = (status) => status === 408 || status === 429 || status >= 500
77
+
78
+ /** POSTs `payload` to `sub.url`, signed, with retries. Resolves { ok, status, attempts, delivery, error? }; never throws. */
79
+ export async function deliverWebhook (sub, payload, { fetch = globalThis.fetch, delays = RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = DELIVERY_TIMEOUT_MS, now = Date.now, log = () => {} } = {}) {
80
+ const body = JSON.stringify(payload)
81
+ const delivery = crypto.randomUUID()
82
+ let status = 0
83
+ let error = ''
84
+ let attempt = 0
85
+ while (attempt < delays.length + 1) {
86
+ attempt++
87
+ const ts = String(now())
88
+ try {
89
+ const res = await fetch(sub.url, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'content-type': 'application/json',
93
+ 'user-agent': 'seam-webhook',
94
+ 'x-quilt-event': String(payload.event || ''),
95
+ 'x-quilt-delivery': delivery,
96
+ 'x-quilt-timestamp': ts,
97
+ 'x-quilt-signature': signWebhook(sub.secret, ts, body),
98
+ ...(sub.bearer ? { authorization: `Bearer ${sub.bearer}` } : {})
99
+ },
100
+ body,
101
+ redirect: 'manual',
102
+ signal: AbortSignal.timeout(timeoutMs)
103
+ })
104
+ status = res.status
105
+ error = ''
106
+ if (res.ok) return { ok: true, status, attempts: attempt, delivery }
107
+ if (!retryable(status)) break
108
+ } catch (err) {
109
+ status = 0
110
+ error = err && err.message ? err.message : String(err)
111
+ }
112
+ if (attempt <= delays.length) await sleep(delays[attempt - 1])
113
+ }
114
+ log(`webhook to ${sub.url} failed (${payload.event}): ${error || `HTTP ${status}`}`)
115
+ return { ok: false, status, attempts: attempt, error: error || `HTTP ${status}`, delivery }
116
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.26.0",
4
- "description": "seam: Quilt Source's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
3
+ "version": "0.130.0",
4
+ "description": "seam: Seam's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "@modelcontextprotocol/sdk": "^1.30.1",
25
25
  "@noble/hashes": "^2.4.0",
26
26
  "ignore": "^7.0.10",
27
+ "node-diff3": "^3.2.1",
27
28
  "zod": "^4.6.5"
28
29
  }
29
30
  }