| [780a7c6] | 1 | /**
|
|---|
| 2 | * Guardianship (FEP-633c §3) — the multi-party handshake state.
|
|---|
| 3 | *
|
|---|
| 4 | * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
|
|---|
| 5 | * site (so the two implementations behave identically and the clients speak
|
|---|
| 6 | * one contract). One row in ap_guardian_offers per offer this instance is a
|
|---|
| 7 | * party to; the accepts accumulate in ap_guardian_offer_accepts.
|
|---|
| 8 | *
|
|---|
| 9 | * The offer commits only when the guardian-candidate returns the handle
|
|---|
| 10 | * (§3.1.3) after ward + candidate + at least one existing guardian have
|
|---|
| 11 | * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
|
|---|
| 12 | * the core safety property: no single party creates a guardianship alone, and
|
|---|
| 13 | * no new guardian is added without an existing guardian's consent.
|
|---|
| 14 | */
|
|---|
| 15 | import db from '../../config/database.js';
|
|---|
| 16 |
|
|---|
| 17 | let _s = null;
|
|---|
| 18 | function stmts() {
|
|---|
| 19 | if (!_s) {
|
|---|
| 20 | _s = {
|
|---|
| 21 | insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
|
|---|
| 22 | (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
|
|---|
| 23 | VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
|
|---|
| 24 | getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
|
|---|
| 25 | offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
|
|---|
| 26 | setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
|
|---|
| 27 | listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
|
|---|
| 28 | insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
|
|---|
| 29 | accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
|
|---|
| 30 | };
|
|---|
| 31 | }
|
|---|
| 32 | return _s;
|
|---|
| 33 | }
|
|---|
| 34 |
|
|---|
| 35 | const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
|
|---|
| 36 | const isParty = (o, actor) => !!actor && parties(o).includes(actor);
|
|---|
| 37 | const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
|
|---|
| 38 |
|
|---|
| 39 | /** ward + candidate + (no existing guardians OR at least one existing) accepted. */
|
|---|
| 40 | export function readyToCommit(o) {
|
|---|
| 41 | if (!o || o.status !== 'pending') return false;
|
|---|
| 42 | const acc = new Set(acceptsOf(o));
|
|---|
| 43 | const existing = JSON.parse(o.existing_guardians || '[]');
|
|---|
| 44 | const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
|
|---|
| 45 | return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
|
|---|
| 46 | }
|
|---|
| 47 |
|
|---|
| 48 | /** Start tracking an offer on `slug` (idempotent). */
|
|---|
| 49 | export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
|
|---|
| 50 | stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
|
|---|
| 51 | return stmts().getOffer.get(slug, offerId);
|
|---|
| 52 | }
|
|---|
| 53 |
|
|---|
| 54 | export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
|
|---|
| 55 | export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
|
|---|
| 56 |
|
|---|
| 57 | /** Record an Accept from one party; ignored if not a party or already resolved. */
|
|---|
| 58 | export function recordAccept(slug, offerId, party) {
|
|---|
| 59 | const o = stmts().getOffer.get(slug, offerId);
|
|---|
| 60 | if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
|
|---|
| 61 | stmts().insAccept.run(offerId, slug, party);
|
|---|
| 62 | return stmts().getOffer.get(slug, offerId);
|
|---|
| 63 | }
|
|---|
| 64 |
|
|---|
| 65 | /** A single Reject from any party voids the handshake (§3.2). */
|
|---|
| 66 | export function recordReject(slug, offerId, party) {
|
|---|
| 67 | const o = stmts().getOffer.get(slug, offerId);
|
|---|
| 68 | if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
|
|---|
| 69 | stmts().setStatus.run('void', null, slug, offerId);
|
|---|
| 70 | return stmts().getOffer.get(slug, offerId);
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| 73 | /** Commit (only when ready): store the returned handle, mark committed. */
|
|---|
| 74 | export function commit(slug, offerId, handle) {
|
|---|
| 75 | const o = stmts().getOffer.get(slug, offerId);
|
|---|
| 76 | if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
|
|---|
| 77 | stmts().setStatus.run('committed', handle || null, slug, offerId);
|
|---|
| 78 | return stmts().getOffer.get(slug, offerId);
|
|---|
| 79 | }
|
|---|
| 80 |
|
|---|
| [30d0e2c] | 81 | /**
|
|---|
| 82 | * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
|
|---|
| 83 | * reversible decision, but not a quick one: the ward, the candidate and every
|
|---|
| 84 | * existing guardian have to answer, and they are people, sometimes on holiday.
|
|---|
| 85 | * A week is long enough that nobody is rushed and short enough that a forgotten
|
|---|
| 86 | * offer does not sit in a child's queue for a month looking like a live choice.
|
|---|
| 87 | */
|
|---|
| 88 | export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|---|
| 89 |
|
|---|
| 90 | /** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
|
|---|
| 91 | * Date.parse reads as LOCAL time — hours out, and enough to expire an offer
|
|---|
| 92 | * early or late. Same correction as ActivityPubService.isoStamp. */
|
|---|
| 93 | const stampMs = (v) => {
|
|---|
| 94 | const s = String(v || '');
|
|---|
| 95 | return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
|
|---|
| 96 | };
|
|---|
| 97 |
|
|---|
| 98 | export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
|
|---|
| 99 |
|
|---|
| 100 | /**
|
|---|
| 101 | * §3.5 fails closed: once the window has run, a handshake that never completed
|
|---|
| 102 | * is over. WHICH failure it was matters (§4.2), so the two get different
|
|---|
| 103 | * terminal states and neither of them is `void`:
|
|---|
| 104 | *
|
|---|
| 105 | * 'expired' — the parties never all answered. Nothing to say about anyone.
|
|---|
| 106 | * 'unverified' — everyone answered; the candidate could never be read, so
|
|---|
| 107 | * the check never got to run. The parties MUST be told this
|
|---|
| 108 | * and MUST NOT be told the candidate was refused. It was not:
|
|---|
| 109 | * nobody ever managed to look.
|
|---|
| 110 | */
|
|---|
| 111 | export function expireIfDue(slug, offerId, now = Date.now()) {
|
|---|
| 112 | const o = stmts().getOffer.get(slug, offerId);
|
|---|
| 113 | if (!o || o.status !== 'pending') return null;
|
|---|
| 114 | const due = closesAt(o);
|
|---|
| 115 | if (!Number.isFinite(due) || due > now) return null;
|
|---|
| 116 | const status = readyToCommit(o) ? 'unverified' : 'expired';
|
|---|
| 117 | stmts().setStatus.run(status, null, slug, offerId);
|
|---|
| 118 | return { ...o, status };
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | /** Pending offers where `me` is a party — the offers queue (daemon shape).
|
|---|
| 122 | * Reads are where lazy completion happens, as with the lapses (§3.6.3): a
|
|---|
| 123 | * closed window is settled here rather than by a sweeper nobody runs. */
|
|---|
| 124 | export function listForParty(slug, me, now = Date.now()) {
|
|---|
| 125 | if (!stmts().listBySlug.get) return [];
|
|---|
| 126 | for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
|
|---|
| 127 | return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
|
|---|
| 128 | }
|
|---|
| 129 |
|
|---|
| 130 | /** Handshakes whose tally is complete but which are not committed: the §4.2
|
|---|
| 131 | * deferred set, waiting on a candidate nobody could dereference. */
|
|---|
| 132 | export function listDeferred(slug) {
|
|---|
| 133 | if (!stmts().listBySlug.get) return [];
|
|---|
| 134 | return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
|
|---|
| [780a7c6] | 135 | }
|
|---|
| 136 |
|
|---|
| 137 | /** One offer as the offers-queue item the Shaer clients parse. */
|
|---|
| 138 | export function queueItem(o, me) {
|
|---|
| 139 | const acc = acceptsOf(o).sort();
|
|---|
| 140 | return {
|
|---|
| 141 | id: o.offer_id,
|
|---|
| 142 | type: 'Offer',
|
|---|
| 143 | actor: o.candidate_uri,
|
|---|
| 144 | object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
|
|---|
| 145 | 'shaer:ward': o.ward_uri,
|
|---|
| 146 | 'shaer:candidate': o.candidate_uri,
|
|---|
| 147 | 'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
|
|---|
| 148 | 'shaer:acceptedBy': acc,
|
|---|
| 149 | 'shaer:needsMyAccept': !acc.includes(me),
|
|---|
| 150 | 'shaer:readyToCommit': readyToCommit(o),
|
|---|
| 151 | 'shaer:iAmCandidate': me === o.candidate_uri,
|
|---|
| 152 | 'shaer:wardHandle': o.ward_handle || undefined,
|
|---|
| 153 | 'shaer:candidateHandle': o.candidate_handle || undefined,
|
|---|
| 154 | published: o.created_at,
|
|---|
| 155 | };
|
|---|
| 156 | }
|
|---|
| 157 |
|
|---|
| 158 | export { parties, isParty, acceptsOf };
|
|---|
| 159 | export default {
|
|---|
| 160 | start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
|
|---|
| 161 | readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
|
|---|
| [30d0e2c] | 162 | OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
|
|---|
| [780a7c6] | 163 | };
|
|---|