@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.
- package/package.json +1 -1
- package/src/main/resources/skill/ringi/SKILL.md +11 -1
- package/target/dts/app/rooms.d.ts +1 -0
- package/target/dts/domain/signals.d.ts +1 -1
- package/target/dts/protocol/index.d.ts +1 -0
- package/target/dts/store/git/index.d.ts +19 -5
- package/target/dts/store/git/remote.d.ts +20 -4
- package/target/dts/store/index.d.ts +4 -1
- package/target/esm/app/rooms.js +8 -1
- package/target/esm/app/session.js +3 -3
- package/target/esm/cli/index.js +1 -0
- package/target/esm/cli/operator.js +4 -0
- package/target/esm/domain/signals.js +21 -16
- package/target/esm/hub/signals.js +5 -5
- package/target/esm/protocol/index.js +2 -0
- package/target/esm/runtime/reader.js +3 -2
- package/target/esm/store/git/index.js +375 -85
- package/target/esm/store/git/remote.js +233 -175
|
@@ -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/
|
|
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
|
|
275
|
-
|
|
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
|
|
278
|
-
if (!
|
|
279
|
-
C.fail(`room ${name} has no
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
360
|
+
C.fail('room changed its genesis');
|
|
301
361
|
return data;
|
|
302
362
|
};
|
|
303
|
-
|
|
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.
|
|
307
|
-
C.fail(`ALARM ${H.remoteAlarms[R.
|
|
308
|
-
return
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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]
|
|
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]
|
|
358
|
-
if (body !==
|
|
359
|
-
C.fail('signal tag
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
367
|
-
|
|
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
|
-
|
|
471
|
-
const
|
|
472
|
-
const byGenesis = new Map();
|
|
473
|
-
for (const
|
|
474
|
-
const
|
|
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 +
|
|
480
|
-
const data =
|
|
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
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
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
|
-
|
|
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 =
|
|
515
|
-
const R =
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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 =
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
if (
|
|
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
|
|
621
|
-
if (
|
|
622
|
-
checkSignal(H, { name:
|
|
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
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
|
676
|
-
|
|
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
|
-
|
|
739
|
+
if (!alarms[R.ref] && !H.remoteAlarms?.[R.ref])
|
|
740
|
+
return;
|
|
741
|
+
const sha = rev(H, tracking(R.ref));
|
|
684
742
|
if (sha)
|
|
685
|
-
|
|
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}/${
|
|
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
|