heyquilt 0.3.119 → 0.3.120
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/RELEASES.md +21 -0
- package/package.json +1 -1
- package/src/api/server.js +10 -1
- package/src/quilts/archiver.js +8 -1
- package/src/quilts/autorevert.js +49 -0
- package/src/quilts/bisect-run.js +31 -0
- package/src/quilts/block.js +133 -14
- package/src/quilts/bucket.js +25 -0
- package/src/quilts/claims.js +144 -0
- package/src/quilts/cli.js +206 -11
- package/src/quilts/client.js +37 -1
- package/src/quilts/describe.js +149 -3
- package/src/quilts/dogfood.js +1 -1
- package/src/quilts/errors.js +8 -0
- package/src/quilts/events.js +19 -5
- package/src/quilts/export-git.js +5 -1
- package/src/quilts/fastmerge.js +2 -2
- package/src/quilts/hash.js +1 -1
- package/src/quilts/history.js +266 -0
- package/src/quilts/import-git.js +154 -56
- package/src/quilts/import.js +56 -20
- package/src/quilts/index.js +34 -2
- package/src/quilts/lines.js +49 -0
- package/src/quilts/live.js +50 -0
- package/src/quilts/merge-smart.js +114 -0
- package/src/quilts/merge.js +75 -27
- package/src/quilts/objects.js +17 -12
- package/src/quilts/records.js +2 -1
- package/src/quilts/resolutions.js +69 -0
- package/src/quilts/reviews.js +195 -0
- package/src/quilts/routes.js +51 -14
- package/src/quilts/service.js +133 -25
- package/src/quilts/setup.js +2 -2
- package/src/quilts/signing.js +149 -0
- package/src/quilts/store-memory.js +152 -0
- package/src/quilts/store-rpc.js +16 -1
- package/src/quilts/tags.js +64 -0
- package/src/quilts/tokens.js +66 -0
- package/src/quilts/tools.js +195 -19
- package/src/quilts/webhook.js +116 -0
- package/src/server.js +2 -2
- package/src/{quilts → source-live}/live-block.js +4 -3
- package/src/{quilts → source-live}/live-square.js +2 -1
|
@@ -19,6 +19,16 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
|
|
|
19
19
|
slugs: new Map(), // owner + slug → quilt id
|
|
20
20
|
members: new Map(), // quilt + member → { level }
|
|
21
21
|
lines: new Map(), // quilt + name → line
|
|
22
|
+
live: new Map(), // square id → its shared uncommitted work { head, changes, actor, at }
|
|
23
|
+
reviews: new Map(), // quilt → review rows (13), oldest first
|
|
24
|
+
signingKeys: new Map(), // key id → { id, member, public_key, name, added_at, removed_at }
|
|
25
|
+
signatures: new Map(), // quilt + commit → signature rows
|
|
26
|
+
tokens: new Map(), // token id → { id, quilt_id, name, level, hash, created_by, created_at, revoked_at } (tokens.js)
|
|
27
|
+
lineRules: new Map(), // quilt → Map of line → { line, approvals, checks }
|
|
28
|
+
squareChecks: new Map(), // square → Map of check name → its latest report
|
|
29
|
+
claims: new Map(), // quilt → Map of claim id → claim (06: rows, not log entries)
|
|
30
|
+
resolutions: new Map(), // quilt → Map of key → remembered resolution (07, D12)
|
|
31
|
+
tags: new Map(), // quilt + name → tag (06: never moved or deleted)
|
|
22
32
|
squares: new Map(), // id → square
|
|
23
33
|
commits: new Map(), // quilt + id → commit row
|
|
24
34
|
bySeq: new Map(), // quilt + seq → commit id
|
|
@@ -44,6 +54,7 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
|
|
|
44
54
|
byMember: new Map(), // member → Set of quilt ids
|
|
45
55
|
quiltMembers: new Map(), // quilt → Set of member ids
|
|
46
56
|
quiltLines: new Map(), // quilt → Set of line names
|
|
57
|
+
quiltTags: new Map(), // quilt → Set of tag names
|
|
47
58
|
quiltSquares: new Map(), // quilt → Set of square ids
|
|
48
59
|
squareCommitIds: new Map(), // square → [commit ids] in seq order
|
|
49
60
|
squareTail: new Map(), // quilt + last 4 hex digits of the id → Set of square ids
|
|
@@ -311,6 +322,120 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
|
|
|
311
322
|
if (sub) t.webhooks.set(k(q, m), { ...sub }); else t.webhooks.delete(k(q, m))
|
|
312
323
|
return sub ? { ...sub } : null
|
|
313
324
|
},
|
|
325
|
+
// A new line (release/<name>) starting at a commit: its next merge is patch 1.
|
|
326
|
+
createLine ({ quilt_id: q, name, tip, actor, at }) {
|
|
327
|
+
quiltRow(q)
|
|
328
|
+
if (t.lines.has(k(q, name))) throw new StoreError('line_exists', `there is already a line ${name}`)
|
|
329
|
+
if (tip && !t.commits.has(k(q, tip))) throw new StoreError('not_found', 'no such commit')
|
|
330
|
+
const row = appendLog(q, { kind: 'policy', line: name, actor, at, payload: { op: 'line', tip: tip || null } })
|
|
331
|
+
t.lines.set(k(q, name), { quilt_id: q, name, tip: tip || null, tip_seq: row.seq, patch_count: 0, frozen: false, updated_at: at })
|
|
332
|
+
addTo(t.quiltLines, q, name)
|
|
333
|
+
return clone(t.lines.get(k(q, name)))
|
|
334
|
+
},
|
|
335
|
+
// A tag (or a release: a tag with notes) on a commit; a name is taken once, for good.
|
|
336
|
+
createTag ({ quilt_id: q, name, commit, release = false, notes = null, actor, at }) {
|
|
337
|
+
quiltRow(q)
|
|
338
|
+
if (t.tags.has(k(q, name))) throw new StoreError('tag_exists', `there is already a tag ${name}`)
|
|
339
|
+
if (!t.commits.has(k(q, commit))) throw new StoreError('not_found', 'no such commit')
|
|
340
|
+
const row = appendLog(q, { kind: 'release', commit, actor, at, payload: { op: release ? 'release' : 'tag', name, ...(release ? { notes } : {}) } })
|
|
341
|
+
t.tags.set(k(q, name), { quilt_id: q, name, commit, release: !!release, notes: release ? notes : null, actor, at, seq: row.seq })
|
|
342
|
+
addTo(t.quiltTags, q, name)
|
|
343
|
+
return clone(t.tags.get(k(q, name)))
|
|
344
|
+
},
|
|
345
|
+
// A member's Ed25519 public key (signing.js): removed, never deleted.
|
|
346
|
+
addSigningKey (r) {
|
|
347
|
+
const had = t.signingKeys.get(r.id)
|
|
348
|
+
if (had && !had.removed_at) throw new StoreError('key_exists', had.member === r.member ? 'you already added this key' : 'this key is someone else\'s')
|
|
349
|
+
if (had && had.member !== r.member) throw new StoreError('key_exists', 'this key is someone else\'s')
|
|
350
|
+
t.signingKeys.set(r.id, { ...r, removed_at: null })
|
|
351
|
+
return clone(t.signingKeys.get(r.id))
|
|
352
|
+
},
|
|
353
|
+
removeSigningKey ({ id, member, at }) {
|
|
354
|
+
const row = t.signingKeys.get(id)
|
|
355
|
+
if (!row || row.member !== member) throw new StoreError('not_found', 'no such key')
|
|
356
|
+
if (!row.removed_at) row.removed_at = at
|
|
357
|
+
return clone(row)
|
|
358
|
+
},
|
|
359
|
+
// A quilt token (tokens.js): a row and its membership token:<id>, made and ended together.
|
|
360
|
+
createToken (r) {
|
|
361
|
+
quiltRow(r.quilt_id)
|
|
362
|
+
if ([...t.tokens.values()].some((x) => x.hash === r.hash)) throw new StoreError('exists', 'this token exists')
|
|
363
|
+
t.tokens.set(r.id, { ...r, revoked_at: null })
|
|
364
|
+
const m = `token:${r.id}`
|
|
365
|
+
t.members.set(k(r.quilt_id, m), { quilt_id: r.quilt_id, member_id: m, level: r.level }); addTo(t.byMember, m, r.quilt_id); addTo(t.quiltMembers, r.quilt_id, m)
|
|
366
|
+
appendLog(r.quilt_id, { kind: 'policy', actor: r.created_by, at: r.created_at, payload: { op: 'token', token: r.id, name: r.name, level: r.level } })
|
|
367
|
+
return clone(t.tokens.get(r.id))
|
|
368
|
+
},
|
|
369
|
+
revokeToken ({ quilt_id: q, id, actor, at }) {
|
|
370
|
+
const row = t.tokens.get(id)
|
|
371
|
+
if (!row || row.quilt_id !== q) throw new StoreError('not_found', 'no such token')
|
|
372
|
+
if (!row.revoked_at) {
|
|
373
|
+
row.revoked_at = at
|
|
374
|
+
t.members.delete(k(q, `token:${id}`))
|
|
375
|
+
appendLog(q, { kind: 'policy', actor, at, payload: { op: 'revoke_token', token: id } })
|
|
376
|
+
}
|
|
377
|
+
return clone(row)
|
|
378
|
+
},
|
|
379
|
+
// A signature on a commit: one per (commit, key), kept for good.
|
|
380
|
+
addSignature (r) {
|
|
381
|
+
if (!t.commits.has(k(r.quilt_id, r.commit))) throw new StoreError('not_found', 'no such commit')
|
|
382
|
+
let a = t.signatures.get(k(r.quilt_id, r.commit)); if (!a) { a = []; t.signatures.set(k(r.quilt_id, r.commit), a) }
|
|
383
|
+
if (!a.some((x) => x.key === r.key)) a.push({ ...r })
|
|
384
|
+
return clone(r)
|
|
385
|
+
},
|
|
386
|
+
// A review row on a square (13): kept for good, never changed.
|
|
387
|
+
addReview (r) {
|
|
388
|
+
const row = t.squares.get(r.square_id)
|
|
389
|
+
if (!row || row.quilt_id !== r.quilt_id) throw new StoreError('not_found', 'no such square')
|
|
390
|
+
let a = t.reviews.get(r.quilt_id); if (!a) { a = []; t.reviews.set(r.quilt_id, a) }
|
|
391
|
+
a.push({ ...r })
|
|
392
|
+
return clone(r)
|
|
393
|
+
},
|
|
394
|
+
// What a line needs to take a merge (13): a number of approvals.
|
|
395
|
+
setLineRule ({ quilt_id: q, line, approvals, checks = [] }) {
|
|
396
|
+
if (!t.lines.has(k(q, line))) throw new StoreError('not_found', `no line ${line}`)
|
|
397
|
+
let m = t.lineRules.get(q); if (!m) { m = new Map(); t.lineRules.set(q, m) }
|
|
398
|
+
m.set(line, { line, approvals, checks: [...checks] })
|
|
399
|
+
return clone(m.get(line))
|
|
400
|
+
},
|
|
401
|
+
// A check's latest result on a square (13): one row per (square, check), replaced by each report.
|
|
402
|
+
setSquareCheck ({ quilt_id: q, square_id: sq, name, status, head, by, at }) {
|
|
403
|
+
const row = t.squares.get(sq)
|
|
404
|
+
if (!row || row.quilt_id !== q) throw new StoreError('not_found', 'no such square')
|
|
405
|
+
let m = t.squareChecks.get(sq); if (!m) { m = new Map(); t.squareChecks.set(sq, m) }
|
|
406
|
+
m.set(name, { name, status, head, by, at })
|
|
407
|
+
return clone(m.get(name))
|
|
408
|
+
},
|
|
409
|
+
// A claim on a square's files (06): a mutable row, not a log entry.
|
|
410
|
+
createClaim ({ quilt_id: q, id, square_id: sq, pattern, holder, note = '', at }) {
|
|
411
|
+
const row = t.squares.get(sq)
|
|
412
|
+
if (!row || row.quilt_id !== q) throw new StoreError('not_found', 'no such square')
|
|
413
|
+
let m = t.claims.get(q); if (!m) { m = new Map(); t.claims.set(q, m) }
|
|
414
|
+
m.set(id, { quilt_id: q, id, square_id: sq, pattern, holder, note, at })
|
|
415
|
+
return clone(m.get(id))
|
|
416
|
+
},
|
|
417
|
+
deleteClaim ({ quilt_id: q, id }) {
|
|
418
|
+
const m = t.claims.get(q)
|
|
419
|
+
if (!m || !m.has(id)) throw new StoreError('not_found', 'no such claim')
|
|
420
|
+
m.delete(id)
|
|
421
|
+
return { ok: true }
|
|
422
|
+
},
|
|
423
|
+
// A square's shared uncommitted work, replaced whole; no changes clears it. Not history: no log row.
|
|
424
|
+
setLive ({ quilt_id: q, square_id: sq, head = null, changes = [], actor, at }) {
|
|
425
|
+
const row = t.squares.get(sq)
|
|
426
|
+
if (!row || row.quilt_id !== q) throw new StoreError('not_found', 'no such square')
|
|
427
|
+
if (!changes.length) { t.live.delete(sq); return null }
|
|
428
|
+
t.live.set(sq, { quilt_id: q, square_id: sq, head, changes: clone(changes), actor, at })
|
|
429
|
+
return clone(t.live.get(sq))
|
|
430
|
+
},
|
|
431
|
+
// Freezes or unfreezes a line: a frozen line refuses merges (line_frozen).
|
|
432
|
+
setLineFrozen ({ quilt_id: q, name, frozen, actor, at }) {
|
|
433
|
+
const ln = t.lines.get(k(q, name))
|
|
434
|
+
if (!ln) throw new StoreError('not_found', `no line ${name}`)
|
|
435
|
+
appendLog(q, { kind: 'policy', line: name, actor, at, payload: { op: frozen ? 'freeze' : 'unfreeze' } })
|
|
436
|
+
Object.assign(ln, { frozen: !!frozen, updated_at: at })
|
|
437
|
+
return clone(ln)
|
|
438
|
+
},
|
|
314
439
|
// The merge window's state on a line (07): current window, maximum, red checks.
|
|
315
440
|
setLineWindow ({ quilt_id: q, line, merge_window: w, merge_window_max: m, red_checks: red }) {
|
|
316
441
|
const ln = t.lines.get(k(q, line))
|
|
@@ -424,6 +549,32 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
|
|
|
424
549
|
async members (q) { return [...(t.quiltMembers.get(q) || [])].map((m) => t.members.get(k(q, m))).filter(Boolean).map(clone) },
|
|
425
550
|
async getLine (q, name) { return clone(t.lines.get(k(q, name))) },
|
|
426
551
|
async lines (q) { return [...(t.quiltLines.get(q) || [])].map((n) => t.lines.get(k(q, n))).filter(Boolean).map(clone) },
|
|
552
|
+
async getLive (q, sq) { const r = t.live.get(sq); return r && r.quilt_id === q ? clone(r) : null },
|
|
553
|
+
// A remembered resolution (07, D12): one row per conflict key, the latest answer wins.
|
|
554
|
+
setResolution (r) {
|
|
555
|
+
let m = t.resolutions.get(r.quilt_id); if (!m) { m = new Map(); t.resolutions.set(r.quilt_id, m) }
|
|
556
|
+
m.set(r.key, { ...r })
|
|
557
|
+
return clone(m.get(r.key))
|
|
558
|
+
},
|
|
559
|
+
deleteResolution ({ quilt_id: q, key }) {
|
|
560
|
+
const m = t.resolutions.get(q)
|
|
561
|
+
if (!m || !m.has(key)) throw new StoreError('not_found', 'no such resolution')
|
|
562
|
+
m.delete(key)
|
|
563
|
+
return { ok: true }
|
|
564
|
+
},
|
|
565
|
+
async resolution (q, key) { return clone(t.resolutions.get(q)?.get(key) || null) },
|
|
566
|
+
async resolutions (q) { return [...(t.resolutions.get(q)?.values() || [])].map(clone) },
|
|
567
|
+
async signingKey (id) { const r = t.signingKeys.get(id); return r ? clone(r) : null },
|
|
568
|
+
async tokens (q) { return [...t.tokens.values()].filter((r) => r.quilt_id === q).sort((a, b) => a.created_at - b.created_at).map(clone) },
|
|
569
|
+
async tokenByHash (hash) { const r = [...t.tokens.values()].find((x) => x.hash === hash); return r ? clone(r) : null },
|
|
570
|
+
async signingKeys (member) { return [...t.signingKeys.values()].filter((r) => r.member === member).sort((a, b) => a.added_at - b.added_at).map(clone) },
|
|
571
|
+
async signaturesOf (q, commits) { return commits.flatMap((c) => t.signatures.get(k(q, c)) || []).map(clone) },
|
|
572
|
+
async reviews (q, sq) { return (t.reviews.get(q) || []).filter((r) => r.square_id === sq).map(clone) },
|
|
573
|
+
async squareChecks (q, sq) { return t.squares.get(sq)?.quilt_id === q ? [...(t.squareChecks.get(sq)?.values() || [])].map(clone) : [] },
|
|
574
|
+
async lineRules (q) { return [...(t.lineRules.get(q)?.values() || [])].map(clone) },
|
|
575
|
+
async claims (q) { return [...(t.claims.get(q)?.values() || [])].map(clone) },
|
|
576
|
+
async getTag (q, name) { return clone(t.tags.get(k(q, name))) },
|
|
577
|
+
async tags (q) { return [...(t.quiltTags.get(q) || [])].map((n) => t.tags.get(k(q, n))).filter(Boolean).map(clone) },
|
|
427
578
|
async getSquare (id) { return clone(t.squares.get(id)) },
|
|
428
579
|
async squares (q, { state, holder, task } = {}) {
|
|
429
580
|
return [...(t.quiltSquares.get(q) || [])].map((id) => t.squares.get(id)).filter((s) => s.quilt_id === q && (!state || s.state === state) && (!holder || s.holder === holder) && (!task || s.task_id === task)).map(clone)
|
|
@@ -523,6 +674,7 @@ export function createMemoryQuiltStore ({ file = null } = {}) {
|
|
|
523
674
|
async recall (key, maxAgeMs, now) { const r = t.idem.get(key); return r && now - r.at < maxAgeMs ? clone(r.result) : null },
|
|
524
675
|
async stubMembers (q) { const out = []; for (const [key, v] of t.stubs) if (v.quilt_id === q) out.push(clone(v)); return out.sort((a, b) => a.member_id.localeCompare(b.member_id)) },
|
|
525
676
|
async gitSha (q, sha) { return t.gitShas.get(k(q, sha)) || null },
|
|
677
|
+
async gitShas (q, shas) { const out = {}; for (const sha of shas) { const id = t.gitShas.get(k(q, sha)); if (id) out[sha] = id } return out },
|
|
526
678
|
async gitShaOf (q, id) { return t.gitOf.get(k(q, id)) || null },
|
|
527
679
|
async getBlock (id) { return clone(t.blocks.get(id)) },
|
|
528
680
|
close () { if (journal) { fs.closeSync(journal); journal = null } }
|
package/src/quilts/store-rpc.js
CHANGED
|
@@ -22,7 +22,7 @@ export function createRpcQuiltStore (rpc) {
|
|
|
22
22
|
createQuilt: 'quilt_create_quilt', setMember: 'quilt_set_member', sealPack: 'quilt_seal_pack', stageFiles: 'quilt_stage_files',
|
|
23
23
|
openSquare: 'quilt_open_square', commit: 'quilt_commit', beginMerge: 'quilt_begin_merge', endMerge: 'quilt_end_merge', merge: 'quilt_merge',
|
|
24
24
|
importCommit: 'quilt_import_commit', setSnapshot: 'quilt_set_snapshot', setSquare: 'quilt_set_square', setGitSha: 'quilt_set_git_sha', remember: 'quilt_remember',
|
|
25
|
-
addEvents: 'quilt_add_events', setWebhook: 'quilt_set_webhook', takeLease: 'quilt_take_lease', openAndCommit: 'quilt_open_and_commit', giveLease: 'quilt_give_lease', addStubs: 'quilt_add_stubs', fillLinePaths: 'quilt_fill_line_paths', setConflicted: 'quilt_set_conflicted', setArchive: 'quilt_set_archive', setLineWindow: 'quilt_set_line_window'
|
|
25
|
+
addEvents: 'quilt_add_events', setWebhook: 'quilt_set_webhook', takeLease: 'quilt_take_lease', openAndCommit: 'quilt_open_and_commit', giveLease: 'quilt_give_lease', addStubs: 'quilt_add_stubs', fillLinePaths: 'quilt_fill_line_paths', setConflicted: 'quilt_set_conflicted', setArchive: 'quilt_set_archive', setLineWindow: 'quilt_set_line_window', createLine: 'quilt_create_line', setLineFrozen: 'quilt_set_line_frozen', createTag: 'quilt_create_tag', setLive: 'quilt_set_live', createClaim: 'quilt_create_claim', deleteClaim: 'quilt_delete_claim', setResolution: 'quilt_set_resolution', deleteResolution: 'quilt_delete_resolution', addReview: 'quilt_add_review', setLineRule: 'quilt_set_line_rule', setSquareCheck: 'quilt_set_square_check', addSigningKey: 'quilt_add_signing_key', removeSigningKey: 'quilt_remove_signing_key', addSignature: 'quilt_add_signature', createToken: 'quilt_create_token', revokeToken: 'quilt_revoke_token'
|
|
26
26
|
}
|
|
27
27
|
const store = {}
|
|
28
28
|
for (const [name, fn] of Object.entries(writes)) store[name] = (args) => call(fn, args)
|
|
@@ -34,6 +34,20 @@ export function createRpcQuiltStore (rpc) {
|
|
|
34
34
|
members: (q) => call('quilt_members_of', { quilt_id: q }),
|
|
35
35
|
getLine: (q, name) => call('quilt_get_line', { quilt_id: q, name }),
|
|
36
36
|
lines: (q) => call('quilt_lines_of', { quilt_id: q }),
|
|
37
|
+
getTag: (q, name) => call('quilt_get_tag', { quilt_id: q, name }),
|
|
38
|
+
getLive: (q, sq) => call('quilt_get_live', { quilt_id: q, square_id: sq }),
|
|
39
|
+
tags: (q) => call('quilt_tags_of', { quilt_id: q }),
|
|
40
|
+
claims: (q) => call('quilt_claims_of', { quilt_id: q }),
|
|
41
|
+
tokens: (q) => call('quilt_tokens_of', { quilt_id: q }),
|
|
42
|
+
tokenByHash: (hash) => call('quilt_token_by_hash', { hash }),
|
|
43
|
+
signingKey: (id) => call('quilt_signing_key', { id }),
|
|
44
|
+
signingKeys: (member) => call('quilt_signing_keys_of', { member }),
|
|
45
|
+
signaturesOf: (q, commits) => call('quilt_signatures_of', { quilt_id: q, commits }),
|
|
46
|
+
reviews: (q, sq) => call('quilt_reviews_of', { quilt_id: q, square_id: sq }),
|
|
47
|
+
squareChecks: (q, sq) => call('quilt_square_checks_of', { quilt_id: q, square_id: sq }),
|
|
48
|
+
lineRules: (q) => call('quilt_line_rules_of', { quilt_id: q }),
|
|
49
|
+
resolution: (q, key) => call('quilt_resolution', { quilt_id: q, key }),
|
|
50
|
+
resolutions: (q) => call('quilt_resolutions_of', { quilt_id: q }),
|
|
37
51
|
getSquare: (id) => call('quilt_get_square', { id }),
|
|
38
52
|
squares: (q, { state, holder, task } = {}) => call('quilt_squares_of', { quilt_id: q, state, holder, task }),
|
|
39
53
|
getCommit: (q, id) => call('quilt_get_commit', { quilt_id: q, id }),
|
|
@@ -57,6 +71,7 @@ export function createRpcQuiltStore (rpc) {
|
|
|
57
71
|
recall: (key, maxAgeMs, now) => call('quilt_recall', { key, max_age_ms: maxAgeMs, now }),
|
|
58
72
|
stubMembers: (q) => call('quilt_stub_members', { quilt_id: q }),
|
|
59
73
|
gitSha: (q, sha) => call('quilt_git_sha', { quilt_id: q, sha }),
|
|
74
|
+
gitShas: async (q, shas) => (await call('quilt_git_shas', { quilt_id: q, shas })) || {},
|
|
60
75
|
gitShaPrefix: (q, prefix) => call('quilt_git_sha_prefix', { quilt_id: q, prefix }),
|
|
61
76
|
gitShaOf: (q, id) => call('quilt_git_sha_of', { quilt_id: q, commit_id: id }),
|
|
62
77
|
close () {}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Tags and releases (plans/quilt-source-control/06): a tag is a name on one commit (a patch or any
|
|
2
|
+
// revision) that never moves and is never deleted; a release is a tag with notes. tag:<name> is a
|
|
3
|
+
// revision everywhere. Anyone who can write tags; the tag records who and when.
|
|
4
|
+
import { QuiltError } from './service.js'
|
|
5
|
+
|
|
6
|
+
const NAME = /^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}$/
|
|
7
|
+
|
|
8
|
+
/** A tag name: letters, digits, dots, dashes, underscores and slashes, no `..`, no trailing slash. */
|
|
9
|
+
export function tagName (raw) {
|
|
10
|
+
const s = String(raw || '').trim()
|
|
11
|
+
if (!NAME.test(s) || s.includes('..') || s.includes('//') || s.endsWith('/')) throw new QuiltError(400, 'bad_request', 'a tag name is letters, digits, dots, dashes, underscores and slashes (up to 100)')
|
|
12
|
+
return s
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function tagOps (svc) {
|
|
16
|
+
const { store, now, quiltFor, resolve, fromStore, getCommit } = svc.internals
|
|
17
|
+
|
|
18
|
+
async function view (q, row) {
|
|
19
|
+
const c = row.commit ? await store.getCommit(q, row.commit) : null
|
|
20
|
+
return { name: row.name, commit: row.commit, short: row.commit ? row.commit.slice(0, 12) : null, patch: c?.patch || null, release: !!row.release, notes: row.notes ?? null, by: row.actor, at: row.at }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function make (me, ref, { name, rev, notes } = {}, release) {
|
|
24
|
+
const q = await quiltFor(me, ref, 'write')
|
|
25
|
+
const n = tagName(name)
|
|
26
|
+
if (notes != null && String(notes).length > 20000) throw new QuiltError(400, 'bad_request', 'release notes are up to 20000 characters')
|
|
27
|
+
const commit = await resolve(q.id, rev || 'main')
|
|
28
|
+
if (!commit) throw new QuiltError(400, 'bad_request', `${rev || 'main'} has no commits to tag`)
|
|
29
|
+
await getCommit(q.id, commit)
|
|
30
|
+
try {
|
|
31
|
+
const row = await store.createTag({ quilt_id: q.id, name: n, commit, release, notes: release ? String(notes ?? '') : null, actor: me.member_id, at: now() })
|
|
32
|
+
return view(q.id, row)
|
|
33
|
+
} catch (err) { throw fromStore(err) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** GET /tags: every tag, by name. */
|
|
37
|
+
async function tags (me, ref) {
|
|
38
|
+
const q = await quiltFor(me, ref, 'read')
|
|
39
|
+
const rows = (await store.tags(q.id)).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
40
|
+
return { tags: await Promise.all(rows.map((r) => view(q.id, r))) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET /releases: the tags that are releases, newest first. */
|
|
44
|
+
async function releases (me, ref) {
|
|
45
|
+
const q = await quiltFor(me, ref, 'read')
|
|
46
|
+
const rows = (await store.tags(q.id)).filter((r) => r.release).sort((a, b) => (b.seq || 0) - (a.seq || 0))
|
|
47
|
+
return { releases: await Promise.all(rows.map((r) => view(q.id, r))) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getTag (me, ref, name) {
|
|
51
|
+
const q = await quiltFor(me, ref, 'read')
|
|
52
|
+
const row = await store.getTag(q.id, String(name))
|
|
53
|
+
if (!row) throw new QuiltError(404, 'not_found', `no tag ${name}`)
|
|
54
|
+
return view(q.id, row)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
tags,
|
|
59
|
+
releases,
|
|
60
|
+
getTag,
|
|
61
|
+
createTag: (me, ref, body) => make(me, ref, body, false),
|
|
62
|
+
createRelease: (me, ref, body) => make(me, ref, body, true)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Quilt tokens: a key for one quilt, read or write, for a CI job or a script. An admin makes one
|
|
2
|
+
// (seam token new ci --level write), the secret (sqt_…) is shown once, and only its sha256 is kept.
|
|
3
|
+
// A token is a member of its one quilt as token:<id> (so every read and write checks it like any
|
|
4
|
+
// other member) and calls as an agent named after the token. Revoking it ends its membership.
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { QuiltError } from './errors.js'
|
|
7
|
+
import { uuidv7 } from './ids.js'
|
|
8
|
+
|
|
9
|
+
export const TOKEN_PREFIX = 'sqt_'
|
|
10
|
+
export const MAX_TOKENS = 50
|
|
11
|
+
const SECRET = /^sqt_[A-Za-z0-9_-]{43}$/
|
|
12
|
+
|
|
13
|
+
export const hashToken = (secret) => crypto.createHash('sha256').update(String(secret)).digest('hex')
|
|
14
|
+
export const isQuiltToken = (s) => SECRET.test(String(s || ''))
|
|
15
|
+
|
|
16
|
+
export function tokenOps (svc) {
|
|
17
|
+
const { store, now, quiltFor, fromStore } = svc.internals
|
|
18
|
+
const view = (r) => ({ id: r.id, name: r.name, level: r.level, created_by: r.created_by, created_at: r.created_at, ...(r.revoked_at ? { revoked_at: r.revoked_at } : {}) })
|
|
19
|
+
|
|
20
|
+
/** POST /tokens { name, level? }: makes a token (admin). The secret is in this answer only. */
|
|
21
|
+
async function createToken (me, ref, { name, level = 'read' } = {}) {
|
|
22
|
+
const q = await quiltFor(me, ref, 'admin')
|
|
23
|
+
const n = String(name || '').trim()
|
|
24
|
+
if (!n || n.length > 100) throw new QuiltError(400, 'bad_request', 'name: what the token is for, up to 100 characters (e.g. ci)')
|
|
25
|
+
if (!['read', 'write'].includes(level)) throw new QuiltError(400, 'bad_request', 'level is read or write')
|
|
26
|
+
const live = (await store.tokens(q.id)).filter((r) => !r.revoked_at)
|
|
27
|
+
if (live.length >= MAX_TOKENS) throw new QuiltError(409, 'too_many_tokens', `up to ${MAX_TOKENS} tokens per quilt; revoke one first`)
|
|
28
|
+
const secret = TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url')
|
|
29
|
+
try {
|
|
30
|
+
const row = await store.createToken({ id: uuidv7(now()), quilt_id: q.id, name: n, level, hash: hashToken(secret), created_by: me.member_id, created_at: now() })
|
|
31
|
+
return { ...view(row), quilt: q.slug, member_id: `token:${row.id}`, token: secret }
|
|
32
|
+
} catch (err) { throw fromStore(err) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /tokens: the quilt's tokens, revoked ones too (admin). Never their secrets. */
|
|
36
|
+
async function tokens (me, ref) {
|
|
37
|
+
const q = await quiltFor(me, ref, 'admin')
|
|
38
|
+
return { quilt: q.slug, tokens: (await store.tokens(q.id)).map(view) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** DELETE /tokens/:id: revokes a token (by id, or by name when one live token has it). */
|
|
42
|
+
async function revokeToken (me, ref, which) {
|
|
43
|
+
const q = await quiltFor(me, ref, 'admin')
|
|
44
|
+
const all = await store.tokens(q.id)
|
|
45
|
+
const w = String(which || '')
|
|
46
|
+
let row = all.find((r) => r.id === w)
|
|
47
|
+
if (!row) {
|
|
48
|
+
const named = all.filter((r) => r.name === w && !r.revoked_at)
|
|
49
|
+
if (named.length > 1) throw new QuiltError(400, 'bad_request', `${named.length} tokens are named ${w}; revoke one by its id`)
|
|
50
|
+
row = named[0]
|
|
51
|
+
}
|
|
52
|
+
if (!row) throw new QuiltError(404, 'not_found', `no token ${w}`)
|
|
53
|
+
if (row.revoked_at) return view(row)
|
|
54
|
+
try { return view(await store.revokeToken({ quilt_id: q.id, id: row.id, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The caller a token secret stands for, or null when it is unknown or revoked. */
|
|
58
|
+
async function tokenCaller (secret) {
|
|
59
|
+
if (!isQuiltToken(secret)) return null
|
|
60
|
+
const row = await store.tokenByHash(hashToken(secret))
|
|
61
|
+
if (!row || row.revoked_at) return null
|
|
62
|
+
return { member_id: `token:${row.id}`, kind: 'agent', name: row.name, tool: 'token', token: { id: row.id, quilt_id: row.quilt_id, level: row.level } }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { createToken, tokens, revokeToken, tokenCaller }
|
|
66
|
+
}
|