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.
Files changed (43) hide show
  1. package/RELEASES.md +21 -0
  2. package/package.json +1 -1
  3. package/src/api/server.js +10 -1
  4. package/src/quilts/archiver.js +8 -1
  5. package/src/quilts/autorevert.js +49 -0
  6. package/src/quilts/bisect-run.js +31 -0
  7. package/src/quilts/block.js +133 -14
  8. package/src/quilts/bucket.js +25 -0
  9. package/src/quilts/claims.js +144 -0
  10. package/src/quilts/cli.js +206 -11
  11. package/src/quilts/client.js +37 -1
  12. package/src/quilts/describe.js +149 -3
  13. package/src/quilts/dogfood.js +1 -1
  14. package/src/quilts/errors.js +8 -0
  15. package/src/quilts/events.js +19 -5
  16. package/src/quilts/export-git.js +5 -1
  17. package/src/quilts/fastmerge.js +2 -2
  18. package/src/quilts/hash.js +1 -1
  19. package/src/quilts/history.js +266 -0
  20. package/src/quilts/import-git.js +154 -56
  21. package/src/quilts/import.js +56 -20
  22. package/src/quilts/index.js +34 -2
  23. package/src/quilts/lines.js +49 -0
  24. package/src/quilts/live.js +50 -0
  25. package/src/quilts/merge-smart.js +114 -0
  26. package/src/quilts/merge.js +75 -27
  27. package/src/quilts/objects.js +17 -12
  28. package/src/quilts/records.js +2 -1
  29. package/src/quilts/resolutions.js +69 -0
  30. package/src/quilts/reviews.js +195 -0
  31. package/src/quilts/routes.js +51 -14
  32. package/src/quilts/service.js +133 -25
  33. package/src/quilts/setup.js +2 -2
  34. package/src/quilts/signing.js +149 -0
  35. package/src/quilts/store-memory.js +152 -0
  36. package/src/quilts/store-rpc.js +16 -1
  37. package/src/quilts/tags.js +64 -0
  38. package/src/quilts/tokens.js +66 -0
  39. package/src/quilts/tools.js +195 -19
  40. package/src/quilts/webhook.js +116 -0
  41. package/src/server.js +2 -2
  42. package/src/{quilts → source-live}/live-block.js +4 -3
  43. 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 } }
@@ -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
+ }