@idleware/ringi 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -2
  3. package/package.json +51 -4
  4. package/src/main/resources/profiles/a2a-1.json +43 -0
  5. package/src/main/resources/skill/ringi/SKILL.md +72 -0
  6. package/target/dts/app/agent.d.ts +104 -0
  7. package/target/dts/app/budget.d.ts +36 -0
  8. package/target/dts/app/context.d.ts +19 -0
  9. package/target/dts/app/hubs.d.ts +38 -0
  10. package/target/dts/app/overview.d.ts +25 -0
  11. package/target/dts/app/room.d.ts +76 -0
  12. package/target/dts/app/rooms.d.ts +26 -0
  13. package/target/dts/app/session.d.ts +60 -0
  14. package/target/dts/app/view.d.ts +64 -0
  15. package/target/dts/cli/agent.d.ts +10 -0
  16. package/target/dts/cli/args.d.ts +13 -0
  17. package/target/dts/cli/context.d.ts +6 -0
  18. package/target/dts/cli/index.d.ts +5 -0
  19. package/target/dts/cli/operator.d.ts +8 -0
  20. package/target/dts/cli/session.d.ts +7 -0
  21. package/target/dts/cli/shared.d.ts +20 -0
  22. package/target/dts/cli/skill.d.ts +4 -0
  23. package/target/dts/cli.d.ts +3 -0
  24. package/target/dts/domain/decisions.d.ts +65 -0
  25. package/target/dts/domain/index.d.ts +2 -0
  26. package/target/dts/domain/profiles.d.ts +39 -0
  27. package/target/dts/domain/signals.d.ts +37 -0
  28. package/target/dts/home/index.d.ts +21 -0
  29. package/target/dts/hub/index.d.ts +66 -0
  30. package/target/dts/hub/signals.d.ts +4 -0
  31. package/target/dts/index.d.ts +8 -0
  32. package/target/dts/lib/index.d.ts +18 -0
  33. package/target/dts/protocol/files.d.ts +9 -0
  34. package/target/dts/protocol/index.d.ts +72 -0
  35. package/target/dts/runtime/reader.d.ts +34 -0
  36. package/target/dts/runtime/session.d.ts +42 -0
  37. package/target/dts/runtime/watch.d.ts +17 -0
  38. package/target/dts/store/git/index.d.ts +64 -0
  39. package/target/dts/store/git/remote.d.ts +70 -0
  40. package/target/dts/store/index.d.ts +102 -0
  41. package/target/dts/store/kafka/index.d.ts +2 -0
  42. package/target/dts/store/temporal/index.d.ts +2 -0
  43. package/target/esm/app/agent.js +83 -0
  44. package/target/esm/app/budget.js +94 -0
  45. package/target/esm/app/context.js +15 -0
  46. package/target/esm/app/hubs.js +75 -0
  47. package/target/esm/app/overview.js +38 -0
  48. package/target/esm/app/room.js +126 -0
  49. package/target/esm/app/rooms.js +119 -0
  50. package/target/esm/app/session.js +96 -0
  51. package/target/esm/app/view.js +51 -0
  52. package/target/esm/cli/agent.js +39 -0
  53. package/target/esm/cli/args.js +60 -0
  54. package/target/esm/cli/context.js +7 -0
  55. package/target/esm/cli/index.js +124 -0
  56. package/target/esm/cli/operator.js +146 -0
  57. package/target/esm/cli/session.js +38 -0
  58. package/target/esm/cli/shared.js +142 -0
  59. package/target/esm/cli/skill.js +53 -0
  60. package/target/esm/cli.js +13 -0
  61. package/target/esm/domain/decisions.js +177 -0
  62. package/target/esm/domain/index.js +15 -0
  63. package/target/esm/domain/profiles.js +95 -0
  64. package/target/esm/domain/signals.js +128 -0
  65. package/target/esm/home/index.js +87 -0
  66. package/target/esm/hub/index.js +211 -0
  67. package/target/esm/hub/signals.js +26 -0
  68. package/target/esm/index.js +8 -0
  69. package/target/esm/lib/index.js +201 -0
  70. package/target/esm/protocol/files.js +18 -0
  71. package/target/esm/protocol/index.js +216 -0
  72. package/target/esm/runtime/reader.js +63 -0
  73. package/target/esm/runtime/session.js +111 -0
  74. package/target/esm/runtime/watch.js +303 -0
  75. package/target/esm/store/git/index.js +345 -0
  76. package/target/esm/store/git/remote.js +699 -0
  77. package/target/esm/store/index.js +14 -0
  78. package/target/esm/store/kafka/index.js +2 -0
  79. package/target/esm/store/temporal/index.js +2 -0
@@ -0,0 +1,699 @@
1
+ // Signed remote hubs for ringi. The remote is an untrusted replica, never a trust anchor.
2
+ // Fetch into staging refs, verify against an out-of-band root pin and a root-signed room roster,
3
+ // then advance local/accepted refs. Private keys stay under this machine's RINGI_HOME.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
9
+ import * as C from '../../lib/index.js';
10
+ import * as P from '../../protocol/index.js';
11
+ const reason = (e) => e instanceof Error ? e.message : String(e);
12
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
13
+ const ROOT = 'operator';
14
+ const TRACK = 'refs/remotes/origin/';
15
+ const ACCEPT = 'refs/ringi/remote-accepted/';
16
+ const TAGS = 'refs/tags/ringi/';
17
+ const TAG_TRACK = 'refs/ringi/remote-tags/';
18
+ const TAG_ACCEPT = 'refs/ringi/remote-accepted-tags/';
19
+ const ENV = { GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', LC_ALL: 'C' };
20
+ const BASE = ['-c', 'core.hooksPath=' + os.devNull, '-c', 'gpg.format=ssh', '-c', 'gpg.ssh.program=ssh-keygen',
21
+ '-c', 'color.ui=false', '-c', 'log.showSignature=false', '-c', 'core.quotePath=false',
22
+ '-c', 'protocol.allow=never', '-c', 'protocol.file.allow=always', '-c', 'protocol.ssh.allow=always',
23
+ '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false'];
24
+ const verified = new Set();
25
+ // Reuse immutable-head checks within one operation; no ref or alarm survives into the next one.
26
+ const checking = new WeakMap();
27
+ export const withVerification = (H, fn) => {
28
+ if (checking.has(H))
29
+ return fn();
30
+ checking.set(H, new Map());
31
+ try {
32
+ return fn();
33
+ }
34
+ finally {
35
+ checking.delete(H);
36
+ }
37
+ };
38
+ const memo = (H, key, fn) => {
39
+ const cache = checking.get(H);
40
+ if (cache?.has(key))
41
+ return cache.get(key);
42
+ const value = fn();
43
+ cache?.set(key, value);
44
+ return value;
45
+ };
46
+ const { note, strictJson } = C;
47
+ export { strictJson };
48
+ const mkdir = (d) => fs.mkdirSync(d, { recursive: true, mode: 0o700 });
49
+ const id = (s) => P.ID_RE.test(s) ? s : C.fail(`bad agent or hub id ${s}`);
50
+ const zero = (H) => '0'.repeat(git(H, ['rev-parse', '--show-object-format']).trim() === 'sha256' ? 64 : 40);
51
+ const ident = (who) => ({ GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: `${who}@ringi`, GIT_COMMITTER_NAME: who, GIT_COMMITTER_EMAIL: `${who}@ringi` });
52
+ export const gitEnv = (extra = {}) => ({
53
+ ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(GIT_CONFIG_|GIT_DIR$|GIT_WORK_TREE$|GIT_INDEX_FILE$|GIT_OBJECT_DIRECTORY$|GIT_ALTERNATE_OBJECT_DIRECTORIES$|GIT_REPLACE_REF_BASE$)/.test(k))),
54
+ ...ENV, ...extra,
55
+ });
56
+ export const gitTimeout = () => {
57
+ const ms = Number(process.env.RINGI_GIT_TIMEOUT_MS ?? 30000);
58
+ if (!Number.isSafeInteger(ms) || ms < 1)
59
+ C.fail('RINGI_GIT_TIMEOUT_MS must be a positive integer (milliseconds)');
60
+ return ms;
61
+ };
62
+ export const checkGitTimeout = (error) => {
63
+ if (error?.code === 'ETIMEDOUT')
64
+ throw Object.assign(new C.Fail('git timed out; increase RINGI_GIT_TIMEOUT_MS (positive integer milliseconds)'), { code: 'ETIMEDOUT' });
65
+ };
66
+ function command(cmd, args, opt = {}) {
67
+ const r = spawnSync(cmd, args, { input: opt.input, encoding: opt.raw ? 'buffer' : 'utf8', env: gitEnv(opt.env),
68
+ maxBuffer: 64 * 1024 * 1024, timeout: cmd === 'git' ? gitTimeout() : 30000 });
69
+ if (cmd === 'git')
70
+ checkGitTimeout(r.error);
71
+ if (r.error && !opt.ok)
72
+ C.fail(`${cmd}: ${r.error.message}`);
73
+ if (r.status !== 0 && !opt.ok)
74
+ C.fail(`${cmd}: ${String(r.stderr || r.error?.message || r.signal || 'failed').trim().split('\n').at(-1)}`);
75
+ return opt.ok ? r : r.stdout;
76
+ }
77
+ function git(H, args, opt) {
78
+ return command('git', ['--git-dir', H.dir, ...BASE, ...args], opt);
79
+ }
80
+ const config = (H, key, value) => value === undefined
81
+ ? String(git(H, ['config', '--get', key], { ok: true }).stdout || '').trim()
82
+ : git(H, ['config', key, String(value)]);
83
+ const rev = (H, ref) => {
84
+ const r = git(H, ['rev-parse', '-q', '--verify', `${ref}^{commit}`], { ok: true });
85
+ return r.status === 0 ? r.stdout.trim() : null;
86
+ };
87
+ const object = (H, ref) => {
88
+ const r = git(H, ['rev-parse', '-q', '--verify', ref], { ok: true });
89
+ return r.status === 0 ? r.stdout.trim() : null;
90
+ };
91
+ const ancestor = (H, a, b) => !!a && !!b && git(H, ['merge-base', '--is-ancestor', a, b], { ok: true }).status === 0;
92
+ export const isRemote = (H) => {
93
+ if (H.d.trust !== 'signed' && H.d.trust !== 'asserted')
94
+ C.fail(`unsupported hub trust ${String(H.d.trust)}`);
95
+ return H.d.trust === 'signed';
96
+ };
97
+ const origin = (H) => H.d.store.remote;
98
+ const suffix = (ref) => ref.replace(/^refs\/heads\//, '');
99
+ const accepted = (ref) => ACCEPT + suffix(ref);
100
+ const tracking = (ref) => TRACK + suffix(ref);
101
+ // Transport and home state use the same owner-aware lock.
102
+ const locked = (H, fn) => {
103
+ const file = path.join(H.dir, 'ringi', 'transport.lock');
104
+ mkdir(path.dirname(file));
105
+ return C.locked(file, () => withVerification(H, fn));
106
+ };
107
+ export const parseKey = (value) => {
108
+ const text = String(value || '').trim();
109
+ const m = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521)) ([A-Za-z0-9+/]+={0,2})(?: [^\r\n]*)?$/.exec(text) ?? C.fail('expected one SSH public key (or a file containing it)');
110
+ if (m[2].length > 16384)
111
+ C.fail('expected one SSH public key (or a file containing it)');
112
+ const data = Buffer.from(m[2], 'base64');
113
+ if (data.length < 8 || data.readUInt32BE(0) > data.length - 4 || data.subarray(4, 4 + data.readUInt32BE(0)).toString() !== m[1])
114
+ C.fail('malformed SSH public key');
115
+ const line = `${m[1]} ${m[2]}`;
116
+ return { line, fp: 'SHA256:' + crypto.createHash('sha256').update(data).digest('base64').replace(/=+$/, '') };
117
+ };
118
+ const keyInput = (value) => parseKey(fs.existsSync(value) && fs.statSync(value).isFile() ? fs.readFileSync(value, 'utf8') : value);
119
+ const rootKey = (H) => parseKey(H.d.root || C.fail(`hub ${H.name} has no out-of-band root pin; use hub add --root <public-key|file>`));
120
+ const signerLine = (who, key) => `${who} namespaces="git" ${key.line}\n`;
121
+ const keyPath = (H, who, home) => path.join(home, 'keys', id(H.name), id(who));
122
+ const localKey = (H, who, home, create = false) => {
123
+ const file = keyPath(H, who, home);
124
+ if (!fs.existsSync(file)) {
125
+ if (!create)
126
+ C.fail(`no private key for ${who} on this machine; ${who === ROOT ? 'root keys are never obtained from a hub' : 'join here to generate one, then ask the root to admit its public key'}`);
127
+ mkdir(path.dirname(file));
128
+ command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `${who}@ringi`, '-f', file]);
129
+ }
130
+ // Derive from the private key instead of trusting a mutable .pub sidecar.
131
+ const key = parseKey(command('ssh-keygen', ['-y', '-f', file]));
132
+ return { ...key, file };
133
+ };
134
+ export const setupSigned = (H, home, url) => {
135
+ id(H.name);
136
+ if (rev(H, 'refs/heads/main'))
137
+ C.fail('setupSigned only initializes an empty hub; it cannot replace an existing trust anchor');
138
+ const key = localKey(H, ROOT, home, true);
139
+ H.d.trust = 'signed';
140
+ H.d.root = key.line;
141
+ config(H, 'gpg.format', 'ssh');
142
+ config(H, 'gpg.ssh.program', 'ssh-keygen');
143
+ if (url) {
144
+ checkUrl(url);
145
+ H.d.store.remote = url;
146
+ }
147
+ return { root: key.line, allowedSigners: signerLine(ROOT, key) };
148
+ };
149
+ export const sign = (H, who, home) => {
150
+ if (!isRemote(H))
151
+ return [];
152
+ const key = localKey(H, who, home);
153
+ if (who === ROOT && key.line !== rootKey(H).line)
154
+ C.fail('the local operator key does not match this hub\'s pinned root');
155
+ config(H, 'gpg.format', 'ssh');
156
+ config(H, 'gpg.ssh.program', 'ssh-keygen');
157
+ return [`-S${key.file}`];
158
+ };
159
+ export const rootRoster = (H) => signerLine(ROOT, rootKey(H));
160
+ const checkUrl = (url) => {
161
+ if (typeof url !== 'string' || /[\r\n\0]/.test(url) || url.startsWith('-') || /^http:\/\//i.test(url) || !/^(?:file|https|ssh):\/\/|^(?:[^\s/@:]+@)?[^\s/:]+:[^\s]+$|^\//.test(url))
162
+ C.fail('use a file://, https:// or ssh Git URL; cleartext HTTP and custom transport helpers are refused');
163
+ };
164
+ const blobText = (H, sha, name) => {
165
+ const entry = git(H, ['ls-tree', sha, '--', name]).trim();
166
+ if (!/^100(?:644|755) blob [0-9a-f]+\t/.test(entry))
167
+ C.fail(`${name} at ${sha.slice(0, 12)} must be a regular blob`);
168
+ const raw = git(H, ['cat-file', 'blob', `${sha}:${name}`], { raw: true });
169
+ const d = P.decodeText(raw);
170
+ if (d.invalid)
171
+ C.fail(`${name} at ${sha.slice(0, 12)} is ${d.invalid}`);
172
+ return d.text;
173
+ };
174
+ const rosterAt = (H, sha) => {
175
+ const entries = blobText(H, sha, 'allowed_signers').split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('#')).map(line => {
176
+ const m = /^(\S+) namespaces="git" (.+)$/.exec(line) ?? C.fail('malformed allowed_signers; only literal principals and git namespace are supported');
177
+ if (!P.ID_RE.test(m[1]))
178
+ C.fail('malformed allowed_signers; only literal principals and git namespace are supported');
179
+ return { principal: m[1], key: parseKey(m[2]) };
180
+ });
181
+ const keys = new Set(), principals = new Set();
182
+ for (const e of entries) {
183
+ if (keys.has(e.key.fp) || principals.has(e.principal))
184
+ C.fail('duplicate key or principal in allowed_signers; rotation requires an explicit revocation protocol');
185
+ keys.add(e.key.fp);
186
+ principals.add(e.principal);
187
+ }
188
+ const members = strictJson(blobText(H, sha, 'members.json'), 'members.json');
189
+ if (!members || typeof members !== 'object' || Array.isArray(members))
190
+ C.fail('members.json must be an object');
191
+ if (entries.find(e => e.principal === ROOT)?.key.line !== rootKey(H).line || members[ROOT]?.role !== 'root')
192
+ C.fail('room roster must retain the pinned operator as root');
193
+ if (Object.keys(members).some(who => !P.ID_RE.test(who) || !principals.has(who) || (who !== ROOT && members[who]?.role === 'root')) || entries.some(e => !members[e.principal]))
194
+ C.fail('members.json and allowed_signers disagree');
195
+ return { entries, members };
196
+ };
197
+ const signerFile = (H, text) => {
198
+ const dir = path.join(H.dir, 'ringi', 'verify');
199
+ mkdir(dir);
200
+ const file = path.join(dir, crypto.createHash('sha256').update(text).digest('hex') + '.allowed_signers');
201
+ if (!fs.existsSync(file))
202
+ fs.writeFileSync(file, text, { flag: 'wx', mode: 0o600 });
203
+ else if (fs.readFileSync(file, 'utf8') !== text)
204
+ C.fail('local signature verification cache was changed');
205
+ return file;
206
+ };
207
+ const checkSignature = (H, sha, who, key, kind = 'commit') => {
208
+ const cache = `${kind}/${sha}/${who}/${key.fp}`;
209
+ if (verified.has(cache))
210
+ return;
211
+ const file = signerFile(H, signerLine(who, key));
212
+ const r = git(H, ['-c', `gpg.ssh.allowedSignersFile=${file}`, '-c', 'gpg.ssh.revocationFile=' + os.devNull, `verify-${kind}`, sha], { ok: true });
213
+ if (r.status !== 0)
214
+ C.fail(`bad or missing signature at ${sha.slice(0, 12)}: not signed by the admitted key of ${who}`);
215
+ const raw = git(H, ['cat-file', kind, sha], { raw: true });
216
+ const split = raw.indexOf(Buffer.from('\n\n'));
217
+ if (split < 0)
218
+ C.fail(`malformed ${kind}`);
219
+ const hdr = raw.subarray(0, split).toString('utf8');
220
+ if (/^encoding (?!UTF-8$|utf-8$)/m.test(hdr))
221
+ C.fail('commit declares a non-UTF-8 message');
222
+ const d = P.decodeText(raw.subarray(split + 2));
223
+ if (d.invalid)
224
+ C.fail(`${kind} message is ${d.invalid}`);
225
+ verified.add(cache);
226
+ if (verified.size > 4096)
227
+ verified.delete(verified.values().next().value);
228
+ };
229
+ const chain = (H, head, base) => git(H, ['rev-list', '--parents', '--reverse', '--topo-order', head, ...(base ? [`^${base}`] : [])]).trim().split('\n').filter(Boolean).map(line => {
230
+ const [sha, ...parents] = line.split(' ');
231
+ return { sha, parents };
232
+ });
233
+ const linear = (commits, initial, check) => {
234
+ let expect = initial;
235
+ for (const { sha, parents: ps } of commits) {
236
+ if (ps.length > 1 || (ps[0] || null) !== expect)
237
+ C.fail(`history is not linear at ${sha.slice(0, 12)}`);
238
+ check(sha);
239
+ expect = sha;
240
+ }
241
+ };
242
+ const verifyMain = (H, head) => memo(H, `main/${head}`, () => {
243
+ const key = rootKey(H);
244
+ const commits = chain(H, head);
245
+ if (!commits.length)
246
+ C.fail('signed hub has no genesis');
247
+ const pin = H.d.genesis;
248
+ if (!pin && !H.bootstrap)
249
+ C.fail(`hub ${H.name} has no pinned genesis`);
250
+ if (pin && commits[0].sha !== pin)
251
+ C.fail('hub does not descend from the pinned genesis');
252
+ linear(commits, null, sha => {
253
+ checkSignature(H, sha, ROOT, key);
254
+ // The shared contract is contract.json; hubs made before the rename carry it as hub.json.
255
+ const name = git(H, ['cat-file', '-e', `${sha}:contract.json`], { ok: true }).status === 0 ? 'contract.json' : 'hub.json';
256
+ const meta = strictJson(blobText(H, sha, name), name);
257
+ if (meta.schema !== 'ringi-hub' || meta.trust !== 'signed' || parseKey(meta.root).line !== key.line)
258
+ C.fail(`${name} must declare signed trust and the pinned root key`);
259
+ const authority = git(H, ['cat-file', '-e', `${sha}:authority.json`], { ok: true });
260
+ if (authority.status === 0)
261
+ strictJson(blobText(H, sha, 'authority.json'), 'authority.json');
262
+ });
263
+ if (!pin)
264
+ H.d.genesis = commits[0].sha;
265
+ H.bootstrap = false;
266
+ return { head, genesis: commits[0].sha, commits: commits.map(c => c.sha) };
267
+ });
268
+ export const verifyHub = (H) => {
269
+ if (!isRemote(H))
270
+ return null;
271
+ const head = rev(H, 'refs/heads/main') || C.fail('signed hub has no main');
272
+ return verifyMain(H, head);
273
+ };
274
+ const roomData = (H, name, head, expectedGenesis) => {
275
+ const data = memo(H, `room/${head}`, () => {
276
+ 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 };
295
+ });
296
+ // A ref name is supplied by the replica; only the signed genesis names the room.
297
+ if (data.subject !== `room ${name}`)
298
+ C.fail(`room genesis subject must be "room ${name}"`);
299
+ if (expectedGenesis && data.genesis !== expectedGenesis)
300
+ C.fail('room control changed its genesis');
301
+ return data;
302
+ };
303
+ export const verifyRoom = (H, R) => {
304
+ if (!isRemote(H))
305
+ 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
+ });
319
+ export const verify = (H, R, who, head) => {
320
+ if (!isRemote(H))
321
+ return null;
322
+ const alarm = H.remoteAlarms?.[R.log(who)];
323
+ if (alarm)
324
+ return alarm;
325
+ try {
326
+ verifyRoom(H, R);
327
+ verifyLog(H, R, who, head);
328
+ return null;
329
+ }
330
+ catch (e) {
331
+ return reason(e);
332
+ }
333
+ };
334
+ 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) => {
345
+ const s = signalName(ref);
346
+ if (s.genesis !== R.genesis)
347
+ C.fail('signal belongs to another room genesis');
348
+ if (git(H, ['cat-file', '-t', sha]).trim() !== 'tag')
349
+ C.fail('signal must be an annotated tag');
350
+ const text = P.decodeText(git(H, ['cat-file', 'tag', sha], { raw: true }));
351
+ if (text.invalid)
352
+ C.fail(`signal tag is ${text.invalid}`);
353
+ const [header, ...rest] = text.text.split('\n\n');
354
+ 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)
356
+ 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}`);
362
+ checkSignature(H, sha, s.actor, signer.key, 'tag');
363
+ const mark = s.signal === 'read' || s.signal === 'taken';
364
+ if (s.message === R.genesis)
365
+ 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)))
390
+ C.fail(`signal points at no accepted message ${mark ? 'of this room' : 'of its actor'}`);
391
+ };
392
+ export const verifySignal = (H, R, ref, sha) => {
393
+ if (!isRemote(H))
394
+ return null;
395
+ const alarm = H.remoteAlarms?.[ref];
396
+ if (alarm)
397
+ return alarm;
398
+ try {
399
+ checkSignal(H, R, ref, sha);
400
+ return null;
401
+ }
402
+ catch (e) {
403
+ return reason(e);
404
+ }
405
+ };
406
+ const refs = (H, prefix) => new Map(git(H, ['for-each-ref', '--format=%(refname) %(objectname)', prefix]).trim().split('\n').filter(Boolean).map(s => s.split(' ')));
407
+ const pushAlarmsFile = (H) => path.join(H.dir, 'ringi', 'push-alarms.json');
408
+ const alarmState = (text, label) => {
409
+ const value = strictJson(text, label);
410
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.values(value).every(v => typeof v === 'string'))
411
+ C.fail(`${label}: expected an object of alarm strings`);
412
+ return value;
413
+ };
414
+ const savePushAlarms = (H, alarms) => {
415
+ const file = pushAlarmsFile(H);
416
+ mkdir(path.dirname(file));
417
+ C.writeJsonAtomic(file, alarms);
418
+ };
419
+ const pushAlarms = (H) => {
420
+ const file = pushAlarmsFile(H);
421
+ try {
422
+ return alarmState(fs.readFileSync(file, 'utf8'), file);
423
+ }
424
+ catch (e) {
425
+ if (C.errno(e) !== 'ENOENT')
426
+ throw e;
427
+ }
428
+ const old = git(H, ['config', '--get', 'ringi.pushAlarms'], { ok: true });
429
+ if (old.status !== 0 && old.status !== 1)
430
+ C.fail(`cannot read legacy push alarm state: ${old.stderr.trim()}`);
431
+ const alarms = alarmState(old.status === 1 ? '{}' : old.stdout, 'legacy ringi.pushAlarms');
432
+ savePushAlarms(H, alarms);
433
+ if (old.status === 0)
434
+ git(H, ['config', '--unset-all', 'ringi.pushAlarms']);
435
+ return alarms;
436
+ };
437
+ // The local accepted transport refs survive reader resets and retain evidence of remote rewrites/deletions.
438
+ const promote = (H, ref, sha) => {
439
+ const acc = rev(H, accepted(ref));
440
+ const cur = rev(H, ref);
441
+ if (acc && !ancestor(H, acc, sha))
442
+ C.fail(`${suffix(ref)} was rewritten or rolled back from ${acc.slice(0, 12)} to ${sha.slice(0, 12)}`);
443
+ if (cur && cur !== sha && !ancestor(H, cur, sha) && !ancestor(H, sha, cur))
444
+ C.fail(`${suffix(ref)} diverges from the local accepted history`);
445
+ if (!cur || (cur !== sha && ancestor(H, cur, sha)))
446
+ git(H, ['update-ref', ref, sha, cur || zero(H)]);
447
+ git(H, ['update-ref', accepted(ref), sha, acc || zero(H)]);
448
+ };
449
+ const syncUnlocked = (H) => {
450
+ H.remoteAlarms = { ...pushAlarms(H) };
451
+ const url = origin(H);
452
+ if (!url) {
453
+ if (rev(H, 'refs/heads/main'))
454
+ verifyHub(H);
455
+ return H;
456
+ }
457
+ checkUrl(url);
458
+ git(H, ['-c', 'fetch.fsckObjects=true', 'fetch', '-q', '--no-tags', '--prune', url,
459
+ `+refs/heads/*:${TRACK}*`, `+${TAGS}*:${TAG_TRACK}*`]);
460
+ const fetched = refs(H, TRACK);
461
+ const main = fetched.get(TRACK + 'main') || C.fail('ALARM remote main was deleted or is missing; refusing authority from the hub');
462
+ try {
463
+ verifyMain(H, main);
464
+ promote(H, 'refs/heads/main', main);
465
+ }
466
+ catch (e) {
467
+ C.fail(`ALARM hub ${H.name}: ${reason(e)}; nothing is delivered`);
468
+ }
469
+ 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;
476
+ try {
477
+ if (!P.ID_RE.test(name))
478
+ 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);
481
+ if (byGenesis.has(data.genesis))
482
+ C.fail('room genesis is already bound to another room');
483
+ 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);
487
+ 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);
504
+ }
505
+ catch (e) {
506
+ H.remoteAlarms[ref] = reason(e);
507
+ }
508
+ }
509
+ const tags = refs(H, TAG_TRACK), pinned = refs(H, TAG_ACCEPT);
510
+ 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))))) {
511
+ const ref = TAGS + name;
512
+ try {
513
+ 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');
516
+ checkSignal(H, R, ref, sha);
517
+ const acc = pinned.get(TAG_ACCEPT + name), cur = object(H, ref);
518
+ if (acc && acc !== sha)
519
+ C.fail('signal was replaced after transport acceptance');
520
+ if (cur && cur !== sha)
521
+ C.fail('signal diverges from the local tag object');
522
+ git(H, ['update-ref', ref, sha, cur ?? zero(H)]);
523
+ git(H, ['update-ref', TAG_ACCEPT + name, sha, acc ?? zero(H)]);
524
+ }
525
+ catch (e) {
526
+ H.remoteAlarms[ref] = reason(e);
527
+ }
528
+ }
529
+ for (const [ref, why] of Object.entries(H.remoteAlarms))
530
+ note(`ALARM ${suffix(ref)}: ${why}; quarantined and withheld`);
531
+ return H;
532
+ };
533
+ export const sync = (H) => !isRemote(H) ? H : locked(H, () => syncUnlocked(H));
534
+ export const hubAdd = ({ home, name, url, root, genesis }) => {
535
+ id(name);
536
+ checkUrl(url);
537
+ if (!root)
538
+ C.fail('hub add requires --root <public-key|file> from the operator; a key advertised by the hub is not a trust anchor');
539
+ const key = keyInput(root ?? C.fail('hub add requires --root <public-key|file>'));
540
+ if (genesis && !SHA.test(genesis))
541
+ C.fail('--genesis must be a full commit id');
542
+ // A hub is a descriptor (hub.json) beside its store; for a git store the repository is store/.git.
543
+ const hubDir = path.join(home, 'hub', name);
544
+ const dir = path.join(hubDir, 'store', '.git');
545
+ if (fs.existsSync(hubDir))
546
+ C.fail(`hub ${name} exists`);
547
+ mkdir(path.dirname(dir));
548
+ const stage = path.join(home, 'hub', `.${name}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.git`);
549
+ try {
550
+ command('git', ['init', '--bare', '-q', '-b', 'main', stage]);
551
+ const descriptor = { schema: 'ringi-hub/2', name, trust: 'signed', root: key.line, genesis,
552
+ store: { type: 'git', path: 'store', remote: url } };
553
+ const H = { name, dir: stage, d: descriptor, bootstrap: !genesis };
554
+ sync(H);
555
+ fs.renameSync(stage, dir);
556
+ fs.writeFileSync(path.join(hubDir, 'hub.json'), JSON.stringify(descriptor, null, 2) + '\n', { mode: 0o600 });
557
+ return dir;
558
+ }
559
+ finally {
560
+ if (fs.existsSync(stage))
561
+ fs.rmSync(stage, { recursive: true, force: true });
562
+ // A failed add leaves no half-made hub behind (the directory did not exist before).
563
+ if (!fs.existsSync(dir))
564
+ fs.rmSync(hubDir, { recursive: true, force: true });
565
+ }
566
+ };
567
+ export const join = (H, R, who, home) => {
568
+ const room = verifyRoom(H, R);
569
+ const key = locked(H, () => localKey(H, who, home, true));
570
+ const admitted = !!room.entries.find(e => e.principal === who && e.key.line === key.line);
571
+ return { publicKey: key.line, fingerprint: key.fp, admitted };
572
+ };
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]}`];
577
+ }));
578
+ for (const [name, content] of Object.entries(patches))
579
+ files.set(name, `100644 blob ${git(H, ['hash-object', '-w', '--stdin'], { input: content }).trim()}\t${name}`);
580
+ return git(H, ['mktree'], { input: [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, line]) => line).join('\n') + '\n' }).trim();
581
+ };
582
+ export const roomAdmit = (H, R, who, publicKey, home) => {
583
+ id(who);
584
+ if (!isRemote(H))
585
+ C.fail('room admit is for signed hubs; local membership is added by join');
586
+ if (who === ROOT)
587
+ C.fail('the root is already pinned; root replacement requires a separate trust transition');
588
+ if (R.archived)
589
+ C.fail('cannot admit into an archived room');
590
+ const room = verifyRoom(H, R);
591
+ 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;
608
+ };
609
+ const validateRef = (H, ref, sha) => {
610
+ 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))
619
+ 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);
623
+ return;
624
+ }
625
+ }
626
+ return C.fail('signal points at no verified room');
627
+ }
628
+ if (ref === 'refs/heads/main') {
629
+ verifyMain(H, sha);
630
+ return;
631
+ }
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) => {
646
+ if (!isRemote(H))
647
+ return;
648
+ return locked(H, () => {
649
+ const alarms = pushAlarms(H);
650
+ // Read the current head after taking the transport lock: an earlier append may have waited behind a newer one.
651
+ const tag = ref.startsWith(TAGS);
652
+ const pin = tag ? TAG_ACCEPT + ref.slice(TAGS.length) : accepted(ref);
653
+ const track = tag ? TAG_TRACK + ref.slice(TAGS.length) : tracking(ref);
654
+ const sha = (tag ? object(H, ref) : rev(H, ref)) || C.fail(`no ${ref} to publish`);
655
+ validateRef(H, ref, sha);
656
+ const url = origin(H);
657
+ if (!url)
658
+ return; // signed source may be provisioned before a remote is attached
659
+ checkUrl(url);
660
+ const acc = tag ? object(H, pin) : rev(H, pin);
661
+ if (acc && (tag ? acc !== sha : !ancestor(H, acc, sha)))
662
+ C.fail('ref to publish does not extend its accepted transport history');
663
+ const r = git(H, ['push', '-q', '--porcelain', url, `${sha}:${ref}`], { ok: true });
664
+ 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`;
666
+ savePushAlarms(H, alarms);
667
+ C.fail(`ALARM ${alarms[ref]}`);
668
+ }
669
+ delete alarms[ref];
670
+ savePushAlarms(H, alarms);
671
+ git(H, ['update-ref', pin, sha, acc || zero(H)]);
672
+ git(H, ['update-ref', track, sha]);
673
+ });
674
+ };
675
+ // Only the operator command calls this after reviewing an alarm. Signature checks are never waived.
676
+ export const forget = (H, R, who) => {
677
+ if (!isRemote(H))
678
+ return;
679
+ id(who);
680
+ const ref = R.log(who);
681
+ return locked(H, () => {
682
+ const alarms = pushAlarms(H);
683
+ const sha = rev(H, tracking(ref));
684
+ if (sha)
685
+ verifyLog(H, R, who, sha);
686
+ const old = rev(H, ref);
687
+ if (old)
688
+ git(H, ['update-ref', `refs/ringi/quarantine/${R.genesis}/${who}/${old}`, old]);
689
+ git(H, ['update-ref', '-d', accepted(ref)]);
690
+ if (sha)
691
+ git(H, ['update-ref', ref, sha, old || zero(H)]);
692
+ else if (old)
693
+ git(H, ['update-ref', '-d', ref, old]);
694
+ delete alarms[ref];
695
+ savePushAlarms(H, alarms);
696
+ if (H.remoteAlarms)
697
+ delete H.remoteAlarms[ref];
698
+ });
699
+ };