@idleware/ringi 0.1.1 → 0.2.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.
@@ -8,12 +8,13 @@ import crypto from 'node:crypto';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import * as C from '../../lib/index.js';
10
10
  import * as P from '../../protocol/index.js';
11
+ const CONTROL_FILES = ['ROOM.md', 'members.json', 'profile.json', 'allowed_signers'];
11
12
  const reason = (e) => e instanceof Error ? e.message : String(e);
12
13
  const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
13
14
  const ROOT = 'operator';
14
15
  const TRACK = 'refs/remotes/origin/';
15
16
  const ACCEPT = 'refs/ringi/remote-accepted/';
16
- const TAGS = 'refs/tags/ringi/';
17
+ const TAGS = 'refs/tags/';
17
18
  const TAG_TRACK = 'refs/ringi/remote-tags/';
18
19
  const TAG_ACCEPT = 'refs/ringi/remote-accepted-tags/';
19
20
  const ENV = { GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', LC_ALL: 'C' };
@@ -271,80 +272,126 @@ export const verifyHub = (H) => {
271
272
  const head = rev(H, 'refs/heads/main') || C.fail('signed hub has no main');
272
273
  return verifyMain(H, head);
273
274
  };
274
- const roomData = (H, name, head, expectedGenesis) => {
275
- const data = memo(H, `room/${head}`, () => {
275
+ export const readCommits = (H, shas) => {
276
+ if (!shas.length)
277
+ return [];
278
+ const out = git(H, ['cat-file', '--batch'], { input: Buffer.from(shas.join('\n') + '\n'), raw: true });
279
+ let at = 0;
280
+ return shas.map(sha => {
281
+ const nl = out.indexOf(0x0a, at);
282
+ const [got, type, size] = out.subarray(at, nl).toString('utf8').split(' ');
283
+ if (got !== sha || type !== 'commit')
284
+ C.fail(`malformed commit ${sha.slice(0, 12)}`);
285
+ const body = out.subarray(nl + 1, nl + 1 + Number(size));
286
+ at = nl + 1 + Number(size) + 1;
287
+ const split = body.indexOf(Buffer.from('\n\n'));
288
+ const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
289
+ const parents = [...hdr.matchAll(/^parent ([0-9a-f]+)$/gm)].map(m => m[1]);
290
+ const author = /^author (.*?) <[^>\n]*> \d+ [+-]\d{4}$/m.exec(hdr)?.[1] ?? '';
291
+ const message = split < 0 ? Buffer.alloc(0) : body.subarray(split + 2);
292
+ return { sha, parents, author, body: message, text: P.decodeText(message).text ?? '' };
293
+ });
294
+ };
295
+ const broken = (why) => Object.assign(new C.Fail(why), { broken: true });
296
+ // A room is one branch on the verified contract: the root's genesis, then members' messages and the root's control
297
+ // records, along first parents. What breaks the room (its genesis, a root-signed control record, the roster) fails it
298
+ // whole; any other commit that is not a valid message of an admitted member is skipped, and the room reads on.
299
+ const branchData = (H, name, tip, expectedGenesis) => {
300
+ const data = memo(H, `branch/${tip}`, () => {
276
301
  const main = verifyHub(H) ?? C.fail('room verification requires a signed hub');
277
- const commits = chain(H, head, main.head);
278
- if (!commits.length)
279
- C.fail(`room ${name} has no control genesis`);
280
- const firstParents = commits[0].parents;
281
- if (firstParents.length !== 1 || !main.commits.includes(firstParents[0]))
282
- C.fail('room control does not descend from the verified hub contract');
283
- let prev = [];
284
- let current;
285
- linear(commits, firstParents[0], sha => {
286
- checkSignature(H, sha, ROOT, rootKey(H));
287
- current = rosterAt(H, sha);
288
- const next = current;
289
- for (const e of prev)
290
- if (!next.entries.some(n => n.principal === e.principal && n.key.line === e.key.line))
291
- C.fail('room roster drops or changes an admitted key; revocation/freeze is not implemented');
292
- prev = current.entries;
293
- });
294
- return { genesis: commits[0].sha, subject: git(H, ['show', '-s', '--format=%B', commits[0].sha]).split('\n')[0], ...current };
302
+ const shas = git(H, ['rev-list', '--first-parent', '--reverse', tip, `^${main.head}`]).trim().split('\n').filter(Boolean);
303
+ if (!shas.length)
304
+ C.fail(`room ${name} has no genesis`);
305
+ const list = readCommits(H, shas);
306
+ const genesis = list[0];
307
+ if (genesis.parents.length !== 1 || !main.commits.includes(genesis.parents[0]))
308
+ C.fail('room does not descend from the verified hub contract');
309
+ const touching = new Set(git(H, ['log', '--first-parent', '--format=%H', tip, `^${main.head}`, '--', ...CONTROL_FILES]).trim().split('\n').filter(Boolean));
310
+ let roster = { entries: [], members: {} };
311
+ const control = [], authors = new Map(), skipped = new Map();
312
+ let parent = genesis.parents[0];
313
+ for (const c of list) {
314
+ if (c.parents.length !== 1 || c.parents[0] !== parent)
315
+ throw broken('a merge or non-linear history; a room is one linear branch');
316
+ parent = c.sha;
317
+ try {
318
+ if (c === genesis || (c.author === ROOT && P.parse(c.text, x => SHA.test(x)).kind === 'room')) {
319
+ try {
320
+ checkSignature(H, c.sha, ROOT, rootKey(H));
321
+ }
322
+ catch (e) {
323
+ if (c === genesis || C.errno(e) === 'ETIMEDOUT')
324
+ throw e;
325
+ C.fail(`${reason(e)}; only the root writes control records`);
326
+ }
327
+ // A control record the root signed is the room's: a broken one breaks the room.
328
+ let next;
329
+ try {
330
+ next = rosterAt(H, c.sha);
331
+ }
332
+ catch (e) {
333
+ throw broken(reason(e));
334
+ }
335
+ for (const e of roster.entries)
336
+ if (!next.entries.some(n => n.principal === e.principal && n.key.line === e.key.line))
337
+ throw broken('room roster drops or changes an admitted key; revocation/freeze is not implemented');
338
+ roster = next;
339
+ control.push(c.sha);
340
+ continue;
341
+ }
342
+ const signer = roster.entries.find(e => e.principal === c.author) ?? C.fail(`${c.author || 'its author'} is unadmitted in this room`);
343
+ if (touching.has(c.sha))
344
+ C.fail('it changes the room\'s control files, which only the root changes');
345
+ checkSignature(H, c.sha, c.author, signer.key);
346
+ authors.set(c.sha, c.author);
347
+ }
348
+ catch (e) {
349
+ if (!(e instanceof C.Fail) || c === genesis || e.broken || C.errno(e) === 'ETIMEDOUT')
350
+ throw e;
351
+ skipped.set(c.sha, reason(e));
352
+ }
353
+ }
354
+ return { genesis: genesis.sha, subject: genesis.text.split('\n')[0], ...roster, control, authors, skipped };
295
355
  });
296
356
  // A ref name is supplied by the replica; only the signed genesis names the room.
297
357
  if (data.subject !== `room ${name}`)
298
358
  C.fail(`room genesis subject must be "room ${name}"`);
299
359
  if (expectedGenesis && data.genesis !== expectedGenesis)
300
- C.fail('room control changed its genesis');
360
+ C.fail('room changed its genesis');
301
361
  return data;
302
362
  };
303
- export const verifyRoom = (H, R) => {
363
+ // The room's branch verified up to `tip`; a quarantined room is not read.
364
+ export const branch = (H, R, tip) => {
304
365
  if (!isRemote(H))
305
366
  C.fail('room verification requires a signed hub');
306
- if (H.remoteAlarms?.[R.control])
307
- C.fail(`ALARM ${H.remoteAlarms[R.control]}; room is quarantined`);
308
- return roomData(H, R.name, R.head, R.genesis);
309
- };
310
- const verifyLog = (H, R, who, head) => memo(H, `log/${R.name}/${R.head}/${R.genesis}/${who}/${head}`, () => {
311
- const room = roomData(H, R.name, R.head, R.genesis);
312
- const signer = room.entries.find(e => e.principal === who) ?? C.fail(`unadmitted key/principal ${who}`);
313
- if (!ancestor(H, R.genesis, head))
314
- C.fail('log does not descend from the room genesis');
315
- const commits = chain(H, head, R.genesis);
316
- linear(commits, R.genesis, sha => checkSignature(H, sha, who, signer.key));
317
- return new Set(commits.map(c => c.sha));
318
- });
367
+ if (H.remoteAlarms?.[R.ref])
368
+ C.fail(`ALARM ${H.remoteAlarms[R.ref]}; room is quarantined`);
369
+ return branchData(H, R.name, tip, R.genesis);
370
+ };
371
+ // The roster in effect at the room's latest control record.
372
+ export const verifyRoom = (H, R) => branch(H, R, R.head);
373
+ // Why `head` is not a valid message of `who`, or null.
319
374
  export const verify = (H, R, who, head) => {
320
375
  if (!isRemote(H))
321
376
  return null;
322
- const alarm = H.remoteAlarms?.[R.log(who)];
323
- if (alarm)
324
- return alarm;
325
377
  try {
326
- verifyRoom(H, R);
327
- verifyLog(H, R, who, head);
328
- return null;
378
+ const b = branch(H, R, head);
379
+ return b.authors.get(head) === who ? null : b.skipped.get(head) ?? `${head.slice(0, 12)} is not a message of ${who}`;
329
380
  }
330
381
  catch (e) {
331
382
  return reason(e);
332
383
  }
333
384
  };
385
+ const SIGNAL_NAME = /^([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*)\.([0-9a-f]{12})$/;
334
386
  const signalName = (ref) => {
335
- const parts = ref.startsWith(TAGS) ? ref.slice(TAGS.length).split('/') : [];
336
- const [genesis, actor, name = ''] = parts;
337
- const dot = name.indexOf('.'), message = name.slice(0, dot), signal = name.slice(dot + 1);
338
- if (parts.length !== 3 || !SHA.test(genesis) || !P.ID_RE.test(actor) || !SHA.test(message) || !/^[a-z][a-z0-9.-]*$/.test(signal))
339
- C.fail('malformed signal tag namespace');
340
- return { genesis, actor, message, signal };
341
- };
342
- // The transport binds a signed tag to an accepted message. Derived signals belong to the message's author;
343
- // read/taken marks may name another member's message. Readers check the transition or mark's meaning (signals.ts).
344
- const checkSignal = (H, R, ref, sha) => {
387
+ const m = ref.startsWith(TAGS) && SIGNAL_NAME.exec(ref.slice(TAGS.length));
388
+ if (!m)
389
+ return C.fail('malformed signal tag namespace');
390
+ return { actor: m[1], id: m[3], signal: m[2] };
391
+ };
392
+ // The signed header carries the full target, tag name and actor; there is no message body.
393
+ const signalObject = (H, ref, sha) => {
345
394
  const s = signalName(ref);
346
- if (s.genesis !== R.genesis)
347
- C.fail('signal belongs to another room genesis');
348
395
  if (git(H, ['cat-file', '-t', sha]).trim() !== 'tag')
349
396
  C.fail('signal must be an annotated tag');
350
397
  const text = P.decodeText(git(H, ['cat-file', 'tag', sha], { raw: true }));
@@ -352,41 +399,25 @@ const checkSignal = (H, R, ref, sha) => {
352
399
  C.fail(`signal tag is ${text.invalid}`);
353
400
  const [header, ...rest] = text.text.split('\n\n');
354
401
  const target = /^object ([0-9a-f]+)\ntype commit\ntag ([^\n]+)\ntagger ([^<\n]+) <[^\n]*> \d+ [+-]\d{4}\n?$/.exec(header) ?? C.fail('malformed signal tag headers');
355
- if (target[1] !== s.message || target[2] !== ref.slice('refs/tags/'.length) || target[3].trim() !== s.actor)
402
+ if (!SHA.test(target[1]) || !target[1].startsWith(s.id) || target[2] !== ref.slice('refs/tags/'.length) || target[3].trim() !== s.actor)
356
403
  C.fail('signal tag name, actor or target disagrees');
357
- const body = rest.join('\n\n').split('-----BEGIN SSH SIGNATURE-----')[0].trimEnd();
358
- if (body !== `${s.signal}\n\nRoom: ${R.name}\nGenesis: ${R.genesis}\nActor: ${s.actor}\nMessage: ${s.message}\nSignal: ${s.signal}`)
359
- C.fail('signal tag trailers disagree with its namespace or target');
360
- const roster = verifyRoom(H, R);
361
- const signer = roster.entries.find(e => e.principal === s.actor) ?? C.fail(`unadmitted signal principal ${s.actor}`);
404
+ const body = rest.join('\n\n').split('-----BEGIN SSH SIGNATURE-----')[0];
405
+ if (body !== '')
406
+ C.fail('signal tag message must be empty');
407
+ return { ...s, message: target[1] };
408
+ };
409
+ // Derived signals belong to their message's author; read/taken marks may name another member's message.
410
+ // The room is determined by the full target commit on its verified branch, never by a short name or a tag body.
411
+ const checkSignal = (H, R, ref, sha) => {
412
+ const s = signalObject(H, ref, sha);
413
+ const b = branch(H, R, rev(H, R.ref) ?? C.fail('signal room has no branch here'));
414
+ const signer = b.entries.find(e => e.principal === s.actor) ?? C.fail(`unadmitted signal principal ${s.actor}`);
362
415
  checkSignature(H, sha, s.actor, signer.key, 'tag');
363
416
  const mark = s.signal === 'read' || s.signal === 'taken';
364
417
  if (s.message === R.genesis)
365
418
  C.fail('signal points at no message of this room');
366
- const heads = refs(H, `refs/heads/${R.name}/log/`);
367
- const snapshot = [...heads].map(([ref, head]) => [ref, head, H.remoteAlarms?.[ref] ?? null]);
368
- const authors = memo(H, `authors/${R.name}/${R.head}/${R.genesis}/${JSON.stringify(snapshot)}`, () => {
369
- const by = new Map();
370
- for (const { principal: who } of roster.entries) {
371
- const head = heads.get(R.log(who));
372
- if (!head || H.remoteAlarms?.[R.log(who)])
373
- continue;
374
- try {
375
- for (const sha of verifyLog(H, R, who, head)) {
376
- const owners = by.get(sha) ?? new Set();
377
- owners.add(who);
378
- by.set(sha, owners);
379
- }
380
- }
381
- catch (e) {
382
- if (C.errno(e) === 'ETIMEDOUT')
383
- throw e;
384
- // A quarantined or invalid log cannot supply a signal target.
385
- }
386
- }
387
- return by;
388
- }).get(s.message);
389
- if (!(mark ? authors?.size : authors?.has(s.actor)))
419
+ const author = b.authors.get(s.message);
420
+ if (!(mark ? author : author === s.actor))
390
421
  C.fail(`signal points at no accepted message ${mark ? 'of this room' : 'of its actor'}`);
391
422
  };
392
423
  export const verifySignal = (H, R, ref, sha) => {
@@ -467,40 +498,25 @@ const syncUnlocked = (H) => {
467
498
  C.fail(`ALARM hub ${H.name}: ${reason(e)}; nothing is delivered`);
468
499
  }
469
500
  H.main = rev(H, 'refs/heads/main');
470
- const controls = new Set([...fetched.keys(), ...refs(H, ACCEPT).keys()].map(r => r.slice(r.startsWith(TRACK) ? TRACK.length : ACCEPT.length)).filter(r => /^[^/]+\/control$/.test(r)));
471
- const rooms = new Map();
472
- const byGenesis = new Map();
473
- for (const ctl of controls) {
474
- const name = ctl.split('/')[0];
475
- const ref = 'refs/heads/' + ctl;
501
+ // Every other branch is a room. Branches of ringi 0.1 (`<room>/control`, `<room>/log/<agent>`) are not read.
502
+ const names = new Set([...fetched.keys(), ...refs(H, ACCEPT).keys()].map(r => r.slice(r.startsWith(TRACK) ? TRACK.length : ACCEPT.length)).filter(r => r !== 'main' && !r.includes('/')));
503
+ const byGenesis = new Map(), byTarget = new Map();
504
+ for (const name of names) {
505
+ const ref = 'refs/heads/' + name;
476
506
  try {
477
507
  if (!P.ID_RE.test(name))
478
508
  C.fail('invalid room name');
479
- const sha = fetched.get(TRACK + ctl) ?? C.fail(`${ctl} was deleted on the remote`);
480
- const data = roomData(H, name, sha);
509
+ const sha = fetched.get(TRACK + name) ?? C.fail(`${name} was deleted on the remote`);
510
+ const data = branchData(H, name, sha);
481
511
  if (byGenesis.has(data.genesis))
482
512
  C.fail('room genesis is already bound to another room');
483
513
  promote(H, ref, sha);
484
- const head = rev(H, ref);
485
- const R = { name, head, genesis: data.genesis, control: ref, log: who => `refs/heads/${name}/log/${who}` };
486
- rooms.set(name, R);
514
+ // Local commits not yet published stay on top.
515
+ const local = branchData(H, name, rev(H, ref), data.genesis);
516
+ const R = { name, ref, head: local.control.at(-1), genesis: data.genesis };
487
517
  byGenesis.set(data.genesis, R);
488
- }
489
- catch (e) {
490
- H.remoteAlarms[ref] = reason(e);
491
- }
492
- }
493
- const logs = new Set([...fetched.keys(), ...refs(H, ACCEPT).keys()].map(r => r.slice(r.startsWith(TRACK) ? TRACK.length : ACCEPT.length)).filter(r => /^[^/]+\/log\/[^/]+$/.test(r)));
494
- for (const log of logs) {
495
- const [name, , who] = log.split('/');
496
- const ref = 'refs/heads/' + log;
497
- try {
498
- if (!P.ID_RE.test(who))
499
- C.fail('invalid log principal');
500
- const R = rooms.get(name) ?? C.fail(`${name}/control is missing or quarantined`);
501
- const sha = fetched.get(TRACK + log) ?? C.fail(`${log} was deleted on the remote`);
502
- verifyLog(H, R, who, sha);
503
- promote(H, ref, sha);
518
+ for (const target of [...local.control, ...local.authors.keys(), ...local.skipped.keys()])
519
+ byTarget.set(target, R);
504
520
  }
505
521
  catch (e) {
506
522
  H.remoteAlarms[ref] = reason(e);
@@ -508,11 +524,14 @@ const syncUnlocked = (H) => {
508
524
  }
509
525
  const tags = refs(H, TAG_TRACK), pinned = refs(H, TAG_ACCEPT);
510
526
  for (const name of new Set([...tags.keys()].map(r => r.slice(TAG_TRACK.length)).concat([...pinned.keys()].map(r => r.slice(TAG_ACCEPT.length))))) {
527
+ // Other tags, including the old room-scoped namespace, are not signals.
528
+ if (!SIGNAL_NAME.test(name))
529
+ continue;
511
530
  const ref = TAGS + name;
512
531
  try {
513
532
  const sha = tags.get(TAG_TRACK + name) ?? C.fail('signal was deleted on the remote after acceptance');
514
- const s = signalName(ref);
515
- const R = byGenesis.get(s.genesis) ?? C.fail('signal room is missing or quarantined');
533
+ const s = signalObject(H, ref, sha);
534
+ const R = byTarget.get(s.message) ?? C.fail('signal room is missing or quarantined');
516
535
  checkSignal(H, R, ref, sha);
517
536
  const acc = pinned.get(TAG_ACCEPT + name), cur = object(H, ref);
518
537
  if (acc && acc !== sha)
@@ -570,15 +589,23 @@ export const join = (H, R, who, home) => {
570
589
  const admitted = !!room.entries.find(e => e.principal === who && e.key.line === key.line);
571
590
  return { publicKey: key.line, fingerprint: key.fp, admitted };
572
591
  };
573
- const treeWith = (H, head, patches) => {
574
- const files = new Map(git(H, ['ls-tree', head]).trim().split('\n').filter(Boolean).map(line => {
575
- const m = /^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/.exec(line) ?? C.fail('malformed control tree');
576
- return [m[4], `${m[1]} ${m[2]} ${m[3]}\t${m[4]}`];
592
+ // A tree from `base` with the control files as `control` has them, then `patches` on top.
593
+ const treeWith = (H, base, control, patches) => {
594
+ const entries = (sha) => new Map(git(H, ['ls-tree', sha]).trim().split('\n').filter(Boolean).map(line => {
595
+ const m = /^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/.exec(line) ?? C.fail('malformed room tree');
596
+ return [m[4], line];
577
597
  }));
598
+ const files = entries(base), kept = entries(control);
599
+ for (const name of CONTROL_FILES) {
600
+ files.delete(name);
601
+ if (kept.has(name))
602
+ files.set(name, kept.get(name));
603
+ }
578
604
  for (const [name, content] of Object.entries(patches))
579
605
  files.set(name, `100644 blob ${git(H, ['hash-object', '-w', '--stdin'], { input: content }).trim()}\t${name}`);
580
606
  return git(H, ['mktree'], { input: [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, line]) => line).join('\n') + '\n' }).trim();
581
607
  };
608
+ // The root's control record admitting a member, on top of the room's branch: the key and the member go in together.
582
609
  export const roomAdmit = (H, R, who, publicKey, home) => {
583
610
  id(who);
584
611
  if (!isRemote(H))
@@ -587,39 +614,38 @@ export const roomAdmit = (H, R, who, publicKey, home) => {
587
614
  C.fail('the root is already pinned; root replacement requires a separate trust transition');
588
615
  if (R.archived)
589
616
  C.fail('cannot admit into an archived room');
590
- const room = verifyRoom(H, R);
591
617
  const key = keyInput(publicKey);
592
- if (room.entries.some(e => e.principal === who || e.key.fp === key.fp))
593
- C.fail('principal or key is already admitted; key rotation is not implemented');
594
- const head = rev(H, R.control) ?? C.fail('room control is missing');
595
- if (head !== R.head)
596
- C.fail('room control moved while admitting; retry');
597
- const members = { ...room.members, [who]: { role: 'member', since: new Date().toISOString() } };
598
- const roster = [...room.entries.map(e => signerLine(e.principal, e.key)), signerLine(who, key)].join('');
599
- const tree = treeWith(H, head, { 'members.json': JSON.stringify(members, null, 2) + '\n', allowed_signers: roster });
600
- const sha = git(H, ['commit-tree', ...sign(H, ROOT, home), tree, '-p', head], {
601
- input: P.compose(`admit ${who}`, [['To', '*'], ['Kind', 'room'], ['Key', key.fp]]), env: ident(ROOT),
602
- }).trim();
603
- git(H, ['update-ref', R.control, sha, head]);
604
- R.head = sha;
605
- R.members = members;
606
- push(H, R.control);
607
- return sha;
618
+ for (let attempt = 0; attempt < 8; attempt++) {
619
+ const tip = rev(H, R.ref) ?? C.fail('room branch is missing');
620
+ const room = branch(H, R, tip), head = room.control.at(-1);
621
+ if (room.entries.some(e => e.principal === who || e.key.fp === key.fp))
622
+ C.fail('principal or key is already admitted; key rotation is not implemented');
623
+ const members = { ...room.members, [who]: { role: 'member', since: new Date().toISOString() } };
624
+ const roster = [...room.entries.map(e => signerLine(e.principal, e.key)), signerLine(who, key)].join('');
625
+ const tree = treeWith(H, tip, head, { 'members.json': JSON.stringify(members, null, 2) + '\n', allowed_signers: roster });
626
+ const sha = git(H, ['commit-tree', ...sign(H, ROOT, home), tree, '-p', tip], {
627
+ input: P.compose(`admit ${who}`, [['To', '*'], ['Kind', 'room'], ['Key', key.fp]]), env: ident(ROOT),
628
+ }).trim();
629
+ if (git(H, ['update-ref', R.ref, sha, tip], { ok: true }).status !== 0)
630
+ continue;
631
+ if (!push(H, R.ref, { behind: true }))
632
+ continue;
633
+ R.head = sha;
634
+ R.members = members;
635
+ return sha;
636
+ }
637
+ return C.fail('room kept moving while admitting; retry');
608
638
  };
609
639
  const validateRef = (H, ref, sha) => {
610
640
  if (ref.startsWith(TAGS)) {
611
- const s = signalName(ref);
612
- const name = refs(H, 'refs/heads/').keys();
613
- for (const ctl of name) {
614
- if (!ctl.endsWith('/control'))
615
- continue;
616
- const roomName = ctl.slice('refs/heads/'.length, -'/control'.length);
617
- const head = rev(H, ctl);
618
- if (!ancestor(H, s.genesis, head))
641
+ const s = signalObject(H, ref, sha);
642
+ for (const [branchRef, head] of refs(H, 'refs/heads/')) {
643
+ const name = branchRef.slice('refs/heads/'.length);
644
+ if (name === 'main' || name.includes('/') || !ancestor(H, s.message, head))
619
645
  continue;
620
- const R = roomData(H, roomName, head);
621
- if (R.genesis === s.genesis) {
622
- checkSignal(H, { name: roomName, head, genesis: R.genesis, control: ctl, log: who => `refs/heads/${roomName}/log/${who}` }, ref, sha);
646
+ const b = branchData(H, name, head);
647
+ if (b.control.includes(s.message) || b.authors.has(s.message) || b.skipped.has(s.message)) {
648
+ checkSignal(H, { name, ref: branchRef, head: b.control.at(-1), genesis: b.genesis }, ref, sha);
623
649
  return;
624
650
  }
625
651
  }
@@ -629,22 +655,18 @@ const validateRef = (H, ref, sha) => {
629
655
  verifyMain(H, sha);
630
656
  return;
631
657
  }
632
- const ctl = /^refs\/heads\/([^/]+)\/control$/.exec(ref);
633
- if (ctl && P.ID_RE.test(ctl[1])) {
634
- roomData(H, ctl[1], sha);
635
- return;
636
- }
637
- const log = /^refs\/heads\/([^/]+)\/log\/([^/]+)$/.exec(ref) ?? C.fail('only explicit main, control or own log refs may be pushed');
638
- if (!P.ID_RE.test(log[1]) || !P.ID_RE.test(log[2]))
639
- C.fail('only explicit main, control or own log refs may be pushed');
640
- const control = `refs/heads/${log[1]}/control`;
641
- const head = rev(H, control) || C.fail('no room control for this log');
642
- const room = roomData(H, log[1], head);
643
- verifyLog(H, { name: log[1], head, genesis: room.genesis }, log[2], sha);
644
- };
645
- export const push = (H, ref) => {
658
+ const room = /^refs\/heads\/([^/]+)$/.exec(ref) ?? C.fail('only explicit main, room or signal refs may be pushed');
659
+ if (!P.ID_RE.test(room[1]))
660
+ C.fail('only explicit main, room or signal refs may be pushed');
661
+ const why = branchData(H, room[1], sha).skipped.get(sha);
662
+ if (why)
663
+ C.fail(`${sha.slice(0, 12)} is no valid commit of room ${room[1]}: ${why}`);
664
+ };
665
+ // Publish one ref. With `behind`, a room's branch the remote has moved on is not an alarm: push returns false and the
666
+ // caller makes its commit again on the new tip.
667
+ export const push = (H, ref, { behind = false } = {}) => {
646
668
  if (!isRemote(H))
647
- return;
669
+ return true;
648
670
  return locked(H, () => {
649
671
  const alarms = pushAlarms(H);
650
672
  // Read the current head after taking the transport lock: an earlier append may have waited behind a newer one.
@@ -655,14 +677,47 @@ export const push = (H, ref) => {
655
677
  validateRef(H, ref, sha);
656
678
  const url = origin(H);
657
679
  if (!url)
658
- return; // signed source may be provisioned before a remote is attached
680
+ return true; // signed source may be provisioned before a remote is attached
659
681
  checkUrl(url);
660
682
  const acc = tag ? object(H, pin) : rev(H, pin);
661
683
  if (acc && (tag ? acc !== sha : !ancestor(H, acc, sha)))
662
684
  C.fail('ref to publish does not extend its accepted transport history');
663
685
  const r = git(H, ['push', '-q', '--porcelain', url, `${sha}:${ref}`], { ok: true });
664
686
  if (r.status !== 0) {
665
- alarms[ref] = `push rejected for ${suffix(ref)}; local commit ${sha.slice(0, 12)} retained; resolve the remote conflict before continuing`;
687
+ let why = '';
688
+ if (behind && !tag && ref !== 'refs/heads/main') {
689
+ try {
690
+ git(H, ['-c', 'fetch.fsckObjects=true', 'fetch', '-q', '--no-tags', url, `+${ref}:${track}`]);
691
+ const incoming = rev(H, track) ?? C.fail('remote room branch is missing');
692
+ const data = branchData(H, suffix(ref), sha);
693
+ branchData(H, suffix(ref), incoming, data.genesis);
694
+ if (acc && !ancestor(H, acc, incoming))
695
+ C.fail('remote room was rewritten or rolled back; retry refused');
696
+ if (ancestor(H, sha, incoming)) {
697
+ // The remote received the message but its acknowledgement was lost.
698
+ promote(H, ref, incoming);
699
+ delete alarms[ref];
700
+ savePushAlarms(H, alarms);
701
+ return true;
702
+ }
703
+ const parents = git(H, ['show', '-s', '--format=%P', sha]).trim().split(' ');
704
+ if (/\[rejected\].*\((?:fetch first|non-fast-forward)\)/.test(String(r.stdout)) && acc && parents.length === 1 && parents[0] === acc) {
705
+ // Only one unpublished message may be retried. Its caller has not returned an id yet.
706
+ git(H, ['update-ref', `refs/ringi/unpublished/${sha}`, sha]);
707
+ git(H, ['update-ref', ref, incoming, sha]);
708
+ git(H, ['update-ref', pin, incoming, acc]);
709
+ delete alarms[ref];
710
+ savePushAlarms(H, alarms);
711
+ if (H.remoteAlarms)
712
+ delete H.remoteAlarms[ref];
713
+ return false;
714
+ }
715
+ }
716
+ catch (e) {
717
+ why = `; ${reason(e)}`;
718
+ }
719
+ }
720
+ alarms[ref] = `push rejected for ${suffix(ref)}; local commit ${sha.slice(0, 12)} retained; resolve the remote conflict before continuing${why}`;
666
721
  savePushAlarms(H, alarms);
667
722
  C.fail(`ALARM ${alarms[ref]}`);
668
723
  }
@@ -670,31 +725,34 @@ export const push = (H, ref) => {
670
725
  savePushAlarms(H, alarms);
671
726
  git(H, ['update-ref', pin, sha, acc || zero(H)]);
672
727
  git(H, ['update-ref', track, sha]);
728
+ return true;
673
729
  });
674
730
  };
675
- // Only the operator command calls this after reviewing an alarm. Signature checks are never waived.
676
- export const forget = (H, R, who) => {
731
+ // Only the operator command calls this after reviewing a room's transport alarm: the room's branch as the remote has
732
+ // it becomes the accepted baseline, and the displaced local branch is kept as evidence. Nothing is waived: the branch
733
+ // must verify, and its invalid commits stay skipped.
734
+ export const forget = (H, R) => {
677
735
  if (!isRemote(H))
678
736
  return;
679
- id(who);
680
- const ref = R.log(who);
681
737
  return locked(H, () => {
682
738
  const alarms = pushAlarms(H);
683
- const sha = rev(H, tracking(ref));
739
+ if (!alarms[R.ref] && !H.remoteAlarms?.[R.ref])
740
+ return;
741
+ const sha = rev(H, tracking(R.ref));
684
742
  if (sha)
685
- verifyLog(H, R, who, sha);
686
- const old = rev(H, ref);
743
+ branchData(H, R.name, sha, R.genesis);
744
+ const old = rev(H, R.ref);
687
745
  if (old)
688
- git(H, ['update-ref', `refs/ringi/quarantine/${R.genesis}/${who}/${old}`, old]);
689
- git(H, ['update-ref', '-d', accepted(ref)]);
746
+ git(H, ['update-ref', `refs/ringi/quarantine/${R.genesis}/${old}`, old]);
747
+ git(H, ['update-ref', '-d', accepted(R.ref)]);
690
748
  if (sha)
691
- git(H, ['update-ref', ref, sha, old || zero(H)]);
749
+ git(H, ['update-ref', R.ref, sha, old || zero(H)]);
692
750
  else if (old)
693
- git(H, ['update-ref', '-d', ref, old]);
694
- delete alarms[ref];
751
+ git(H, ['update-ref', '-d', R.ref, old]);
752
+ delete alarms[R.ref];
695
753
  savePushAlarms(H, alarms);
696
754
  if (H.remoteAlarms)
697
- delete H.remoteAlarms[ref];
755
+ delete H.remoteAlarms[R.ref];
698
756
  });
699
757
  };
700
758
  //# sourceMappingURL=remote.js.map