| 1 | // Everything on this machine behaves as if every Klonkt were somewhere else.
|
|---|
| 2 | //
|
|---|
| 3 | // Co-location is a TRANSPORT detail: a delivery to a local actor is looped back
|
|---|
| 4 | // into the same inbox handler instead of crossing a socket, and nothing above
|
|---|
| 5 | // that line knows the difference. The rule exists because two bugs in one week
|
|---|
| 6 | // came from a second, local-only path hiding a broken remote one:
|
|---|
| 7 | // - the Undo never reached a co-located ward (you cannot HTTP your own inbox);
|
|---|
| 8 | // - the gated proposal was broken over the wire, while the local shortcut
|
|---|
| 9 | // recorded the vote directly and looked perfectly fine on the dashboard.
|
|---|
| 10 | //
|
|---|
| 11 | // So the scenario below runs TWICE, all-local and all-remote, and both runs must
|
|---|
| 12 | // land in the same place. A shortcut that decides something for a local party
|
|---|
| 13 | // shows up here as a difference between the two.
|
|---|
| 14 | import { test } from 'node:test';
|
|---|
| 15 | import assert from 'node:assert/strict';
|
|---|
| 16 | import fs from 'fs';
|
|---|
| 17 |
|
|---|
| 18 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 19 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 20 |
|
|---|
| 21 | const dbMod = await import('../src/config/database.js');
|
|---|
| 22 | const db = dbMod.default;
|
|---|
| 23 | dbMod.initializeDatabase();
|
|---|
| 24 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 25 | const G = await import('../src/services/guardianship/index.js');
|
|---|
| 26 |
|
|---|
| 27 | const BASE = 'https://test.example';
|
|---|
| 28 | const local = (slug) => `${BASE}/ap/users/${slug}`;
|
|---|
| 29 | const remote = (name) => `https://${name}.test/u/${name}`;
|
|---|
| 30 |
|
|---|
| 31 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 32 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 33 | let n = 0;
|
|---|
| 34 | function site(slug) {
|
|---|
| 35 | db.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)')
|
|---|
| 36 | .run(`s${++n}`, slug, slug, 'u1', n === 1 ? 1 : 0);
|
|---|
| 37 | return db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
|
|---|
| 38 | }
|
|---|
| 39 | const guardianship = (slug, role, other) =>
|
|---|
| 40 | db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id)
|
|---|
| 41 | VALUES (?,?,?,'accepted','o')`).run(slug, role, other);
|
|---|
| 42 |
|
|---|
| 43 | /**
|
|---|
| 44 | * One ward with three guardians, in a chosen topology. `local` puts all three
|
|---|
| 45 | * on this instance, `remote` puts them elsewhere. The ward is local either way:
|
|---|
| 46 | * the ward's server is the one under test, because it is the one that tallies.
|
|---|
| 47 | */
|
|---|
| 48 | function world(prefix, topology) {
|
|---|
| 49 | const wardSlug = `${prefix}ward`;
|
|---|
| 50 | const wardSite = site(wardSlug);
|
|---|
| 51 | const guardians = ['a', 'b', 'c'].map((g) => {
|
|---|
| 52 | if (topology !== 'local') return remote(`${prefix}${g}`);
|
|---|
| 53 | site(`${prefix}${g}`); // a real site, so the loopback lands somewhere
|
|---|
| 54 | guardianship(`${prefix}${g}`, 'guardian', local(wardSlug));
|
|---|
| 55 | return local(`${prefix}${g}`);
|
|---|
| 56 | });
|
|---|
| 57 | for (const g of guardians) guardianship(wardSlug, 'ward', g);
|
|---|
| 58 | return { wardSlug, wardSite, guardians, topology };
|
|---|
| 59 | }
|
|---|
| 60 |
|
|---|
| 61 | // Deliveries are recorded and then handed to the real deliverToActor, so a
|
|---|
| 62 | // local recipient travels the same loopback the deployment uses. That is the
|
|---|
| 63 | // point: the test drives the road production drives.
|
|---|
| 64 | const sent = [];
|
|---|
| 65 | G.wireHandshake({
|
|---|
| 66 | selfId: (slug) => local(slug),
|
|---|
| 67 | localSlug: (u) => (u && u.startsWith(`${BASE}/ap/users/`) ? u.split('/').pop() : null),
|
|---|
| 68 | deriveHandle: (u) => `@${String(u).split('/').pop()}`,
|
|---|
| 69 | fetchActor: async (u) => ({ id: u, inbox: `${u}/inbox` }),
|
|---|
| 70 | deliverTo: async (fromSite, toUri, activity) => {
|
|---|
| 71 | sent.push({ from: fromSite.slug, to: toUri, activity });
|
|---|
| 72 | return AP.deliverToActor(fromSite, toUri, activity);
|
|---|
| 73 | },
|
|---|
| 74 | onEvent: null,
|
|---|
| 75 | });
|
|---|
| 76 |
|
|---|
| 77 | /** What the ward's server holds about one gated decision. */
|
|---|
| 78 | function gatedState(wardSlug, feature) {
|
|---|
| 79 | const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
|
|---|
| 80 | .all(wardSlug, feature);
|
|---|
| 81 | return {
|
|---|
| 82 | voted: votes.length,
|
|---|
| 83 | yes: votes.filter((v) => v.value === 1).length,
|
|---|
| 84 | setting: db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(wardSlug).external_embeds,
|
|---|
| 85 | };
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | /**
|
|---|
| 89 | * Guardian A proposes link previews. The ward's server records A's own answer
|
|---|
| 90 | * (§3.1's one-step clause) and forwards to B and C. Then B agrees, and two of
|
|---|
| 91 | * three settles it.
|
|---|
| 92 | */
|
|---|
| 93 | async function proposeAndSettle({ wardSlug, wardSite, guardians }) {
|
|---|
| 94 | const [A, B] = guardians;
|
|---|
| 95 | const offerId = `${A}/gated/1`;
|
|---|
| 96 | const offer = G.gated.buildGatedOffer(offerId, A, local(wardSlug), 'shaer:externalEmbeds', true);
|
|---|
| 97 | await G.handleGuardianshipInbox(wardSite, offer);
|
|---|
| 98 | const afterPropose = gatedState(wardSlug, 'shaer:externalEmbeds');
|
|---|
| 99 | await G.handleGuardianshipInbox(wardSite, { id: `${B}/accept/1`, type: 'Accept', actor: B, object: offerId });
|
|---|
| 100 | return { afterPropose, afterSecond: gatedState(wardSlug, 'shaer:externalEmbeds') };
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| 103 | const localRun = await proposeAndSettle(world('loc', 'local'));
|
|---|
| 104 | const remoteRun = await proposeAndSettle(world('rem', 'remote'));
|
|---|
| 105 |
|
|---|
| 106 | test('a gated decision reaches the same state whether the guardians are local or remote', () => {
|
|---|
| 107 | assert.deepEqual(localRun.afterPropose, remoteRun.afterPropose,
|
|---|
| 108 | 'the tally after one proposal must not depend on where the guardians live');
|
|---|
| 109 | assert.deepEqual(localRun.afterSecond, remoteRun.afterSecond,
|
|---|
| 110 | 'nor the outcome after the second answer');
|
|---|
| 111 | // And the shared state is the RIGHT one, so "identical" cannot be satisfied
|
|---|
| 112 | // by both sides being equally broken.
|
|---|
| 113 | assert.equal(localRun.afterPropose.voted, 1, 'one voice after the proposal');
|
|---|
| 114 | assert.equal(localRun.afterPropose.setting, null, 'one voice does not open a gate');
|
|---|
| 115 | assert.equal(localRun.afterSecond.setting, 1, 'two of three does');
|
|---|
| 116 | assert.equal(localRun.afterSecond.voted, 0, 'and the settled decision is cleared');
|
|---|
| 117 | });
|
|---|
| 118 |
|
|---|
| 119 | test('the other guardians are told, local ones no less than remote ones', () => {
|
|---|
| 120 | const fwd = sent.filter((x) => x.activity.object && x.activity.object['shaer:feature']);
|
|---|
| 121 | assert.equal(fwd.filter((x) => x.to.startsWith(BASE)).length, 2,
|
|---|
| 122 | 'a guardian on this machine is forwarded to, not skipped for being nearby');
|
|---|
| 123 | assert.equal(fwd.filter((x) => !x.to.startsWith(BASE)).length, 2, 'and so is one elsewhere');
|
|---|
| 124 | for (const x of fwd) {
|
|---|
| 125 | // The forward is signed by the ward's key, so the body must name the ward.
|
|---|
| 126 | // Its absence is what made boiert.eu answer 401 to every forward.
|
|---|
| 127 | assert.match(x.activity.actor, /ward$/, 'the ward relays it under its own name');
|
|---|
| 128 | assert.ok(x.activity['shaer:proposer'], 'with the proposer carried alongside');
|
|---|
| 129 | }
|
|---|
| 130 | });
|
|---|
| 131 |
|
|---|
| 132 | test('a local guardian ends up with a review it can actually answer', () => {
|
|---|
| 133 | // The loopback is only worth having if it produces the same effect on the
|
|---|
| 134 | // receiving side as an HTTP delivery would: a stored proposal, on the
|
|---|
| 135 | // guardian's own dashboard.
|
|---|
| 136 | for (const g of ['a', 'b', 'c'].slice(1)) {
|
|---|
| 137 | const reviews = G.gated.listGatedReviews(`loc${g}`);
|
|---|
| 138 | assert.equal(reviews.length, 1, `guardian loc${g} holds the forwarded proposal`);
|
|---|
| 139 | assert.equal(reviews[0].feature, 'shaer:externalEmbeds');
|
|---|
| 140 | assert.equal(reviews[0].proposer, local('loca'), 'and can see who proposed it');
|
|---|
| 141 | }
|
|---|
| 142 | });
|
|---|
| 143 |
|
|---|
| 144 | // ── §3.6.1 away: the other activity that has to cross the same gap ──
|
|---|
| 145 | // A guardian declaring itself away rides a direct note. Direct notes did NOT
|
|---|
| 146 | // take the loopback: they resolved an inbox and POSTed to it, so a note to a
|
|---|
| 147 | // ward on this machine went out to our own hostname and back, or nowhere. Two
|
|---|
| 148 | // hand-written shortcuts existed to paper over it (one in the C2S outbox, one
|
|---|
| 149 | // in the Guardian PWA route), which is the pattern this file is about.
|
|---|
| 150 | const awaySite = site('awguard');
|
|---|
| 151 | const awayWard = site('awward');
|
|---|
| 152 | guardianship('awward', 'ward', local('awguard'));
|
|---|
| 153 | guardianship('awguard', 'guardian', local('awward'));
|
|---|
| 154 | const AWAY_UNTIL = Date.now() + 9 * 24 * 3600 * 1000;
|
|---|
| 155 | const awayNote = await AP.deliverDirectNote(awaySite, {
|
|---|
| 156 | recipients: [local('awward')], text: 'even weg', awayUntil: AWAY_UNTIL,
|
|---|
| 157 | });
|
|---|
| 158 |
|
|---|
| 159 | test('a guardian on this machine can declare itself away to a ward on this machine', () => {
|
|---|
| 160 | assert.ok(awayNote && awayNote.id, 'the note was built');
|
|---|
| 161 | assert.equal(awayNote.delivered, 1, 'and it was delivered, not silently dropped for being local');
|
|---|
| 162 | assert.equal(G.availability.effective('awward', local('awguard'), Date.now()), 'away',
|
|---|
| 163 | 'the ward server recorded the absence, through the inbox handler like anyone else');
|
|---|
| 164 | });
|
|---|
| 165 |
|
|---|
| 166 | test('the same note read from the wire produces the same state for a remote guardian', () => {
|
|---|
| 167 | // The body the loopback carried is the body an HTTP POST would carry, so
|
|---|
| 168 | // replaying it from a remote actor must land in exactly the same place. This
|
|---|
| 169 | // is what makes the two paths one path rather than two that agree today.
|
|---|
| 170 | const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(awayNote.id);
|
|---|
| 171 | const note = AP.buildReplyNote(BASE, awaySite, row);
|
|---|
| 172 | assert.equal(note['shaer:away'], true, 'shaer:away rides on the note itself (§3.6.1)');
|
|---|
| 173 | assert.ok(note.endTime, 'with an end: an absence without one is dropped, never guessed');
|
|---|
| 174 |
|
|---|
| 175 | const rw = site('awward2');
|
|---|
| 176 | const far = remote('farg');
|
|---|
| 177 | guardianship('awward2', 'ward', far);
|
|---|
| 178 | // The same note, re-addressed to the second ward and sent by a guardian
|
|---|
| 179 | // elsewhere: the mention tag is how a recipient recognises itself, so it
|
|---|
| 180 | // travels along. Nothing else about the body changes.
|
|---|
| 181 | const readdressed = JSON.parse(JSON.stringify(note).split(local('awward')).join(local('awward2')));
|
|---|
| 182 | const create = { type: 'Create', actor: far, to: [local('awward2')], object: { ...readdressed, id: `${note.id}#2`, attributedTo: far } };
|
|---|
| 183 | return AP.handleInbox(
|
|---|
| 184 | { body: create, ip: '1.2.3.4', protocol: 'https', get: () => 'test.example', headers: {} },
|
|---|
| 185 | rw.slug, { id: far },
|
|---|
| 186 | ).then(() => {
|
|---|
| 187 | assert.equal(G.availability.effective('awward2', far, Date.now()),
|
|---|
| 188 | G.availability.effective('awward', local('awguard'), Date.now()),
|
|---|
| 189 | 'a remote guardian and a co-located one leave the ward in the same state');
|
|---|
| 190 | });
|
|---|
| 191 | });
|
|---|
| 192 |
|
|---|
| 193 | test('the loopback still checks the signer against the actor', async () => {
|
|---|
| 194 | // The loopback hands the inbox a verified signer instead of a signature. If
|
|---|
| 195 | // that were taken on faith, a local delivery would be the one place where a
|
|---|
| 196 | // forged actor passes. It is not: the same check runs.
|
|---|
| 197 | site('mmward'); const b = site('mmguard');
|
|---|
| 198 | const status = await AP.handleInbox(
|
|---|
| 199 | { body: { type: 'Offer', actor: local('someone-else'), object: {} }, ip: 'loopback', protocol: 'https', get: () => 'test.example', headers: {} },
|
|---|
| 200 | b.slug,
|
|---|
| 201 | { id: local('mmward') }, // signed as mmward, body claims someone else
|
|---|
| 202 | );
|
|---|
| 203 | assert.equal(status, 401, 'signer mismatch is refused on the loopback too');
|
|---|
| 204 | });
|
|---|
| 205 |
|
|---|
| 206 | test('no guardianship decision takes a shortcut for a local party', () => {
|
|---|
| 207 | // A guard, not a proof. Every place that asks "is this actor one of ours?"
|
|---|
| 208 | // must sit in a function that is allowed to ask: one that WRITES what this
|
|---|
| 209 | // instance hosts after a decision, or READS local state for display. Never
|
|---|
| 210 | // one that decides instead of delivering.
|
|---|
| 211 | //
|
|---|
| 212 | // Adding a name here should feel like a decision. If a new function needs a
|
|---|
| 213 | // local branch in a decision path, that is the bug, not this list.
|
|---|
| 214 | const allowed = {
|
|---|
| 215 | existingGuardiansOf: 'reads our own guardian list instead of fetching our own actor doc',
|
|---|
| 216 | candidateFitness: '§4.2: same question, same source — is this candidate a ward? Our table, not a self-fetch',
|
|---|
| 217 | applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts',
|
|---|
| 218 | endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout',
|
|---|
| 219 | proposeGated: 'reads the tally back for the screen, after delivering',
|
|---|
| 220 | wardGuardianStatuses: 'availability of a ward we host: our own state, for our own screen',
|
|---|
| 221 | wardGateSetting: 'the gate of a ward we host: our own column, for our own screen',
|
|---|
| 222 | 'route /wards/release-check': 'counts a ward\'s guardians: ours from the table, someone else\'s from their actor doc',
|
|---|
| 223 | // Known second path, NOT blessed: a gated follow reaches a co-located
|
|---|
| 224 | // guardian through the shared database instead of an Offer, and the answer
|
|---|
| 225 | // travels back the same way. Listed so this guard keeps passing while it
|
|---|
| 226 | // exists, not so it can be forgotten. Removing this line is the definition
|
|---|
| 227 | // of that job being done.
|
|---|
| 228 | wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
|
|---|
| 229 | // Tweede plek van diezelfde sluiproute, aangekomen met Fase 2 (shaer-jdb):
|
|---|
| 230 | // followsCollection vult de wachtrij voor een ward op DEZE instance uit
|
|---|
| 231 | // ap_pending_follows. Hoort mee te verdwijnen met wardSlugsOf hierboven --
|
|---|
| 232 | // niet apart, want het is een sluiproute en geen tweede probleem.
|
|---|
| 233 | slugOf: 'TODO: idem, nu ook in de follows-wachtrij (shaer-h6u)',
|
|---|
| 234 | };
|
|---|
| 235 | // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke
|
|---|
| 236 | // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten
|
|---|
| 237 | // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er
|
|---|
| 238 | // geruisloos doorheen kwam. Dat is precies hoe deze er kwam.
|
|---|
| 239 | const files = [
|
|---|
| 240 | ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js'))
|
|---|
| 241 | .map((f) => `src/services/guardianship/${f}`),
|
|---|
| 242 | 'src/routes/guardian.js',
|
|---|
| 243 | ];
|
|---|
| 244 | // Only top-level declarations name a scope; an indented `const base = ...` is
|
|---|
| 245 | // a local and would otherwise take the blame for its enclosing function. A
|
|---|
| 246 | // route handler is an anonymous arrow, so it is named after its path.
|
|---|
| 247 | const declares = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)|^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\()/;
|
|---|
| 248 | const routes = /^router\.\w+\(\s*['"`]([^'"`]+)/;
|
|---|
| 249 | for (const f of files) {
|
|---|
| 250 | const lines = fs.readFileSync(f, 'utf8').split('\n');
|
|---|
| 251 | let fn = '<top level>';
|
|---|
| 252 | for (const [i, line] of lines.entries()) {
|
|---|
| 253 | const d = line.match(declares);
|
|---|
| 254 | const r = line.match(routes);
|
|---|
| 255 | if (d) fn = d[1] || d[2];
|
|---|
| 256 | else if (r) fn = `route ${r[1]}`;
|
|---|
| 257 | // The idioms for "this URI is on this machine": ask the helper, or
|
|---|
| 258 | // compare the URI against our own base.
|
|---|
| 259 | if (!/localSlug\b|\.startsWith\(\s*(?:`\$\{base\}|base\b)/.test(line)) continue;
|
|---|
| 260 | if (/^\s*(\*|\/\/)/.test(line)) continue; // a comment about it is fine
|
|---|
| 261 | assert.ok(allowed[fn], `${f}:${i + 1} branches on co-location inside ${fn}(), which is not on the list:\n ${line.trim()}`);
|
|---|
| 262 | }
|
|---|
| 263 | }
|
|---|
| 264 | });
|
|---|