| 1 | /**
|
|---|
| 2 | * Guardianship (FEP-633c §5.3) — follow-gating for wards.
|
|---|
| 3 | *
|
|---|
| 4 | * A `Follow` targeting a ward is NOT auto-accepted. It is held pending and
|
|---|
| 5 | * routed to the ward's guardians, who approve or deny. A committed guardian's
|
|---|
| 6 | * own Follow is auto-accepted (it needs no gate). Quorum policy per ward:
|
|---|
| 7 | * 'any' (one guardian suffices, default), 'all', or 'none' (open).
|
|---|
| 8 | *
|
|---|
| 9 | * This module is the store + the decision; the AP plumbing (sending the
|
|---|
| 10 | * Accept, inserting the follower) stays in ActivityPubService.
|
|---|
| 11 | */
|
|---|
| 12 | import db from '../../config/database.js';
|
|---|
| 13 |
|
|---|
| 14 | let _s = null;
|
|---|
| 15 | function stmts() {
|
|---|
| 16 | if (!_s) {
|
|---|
| 17 | _s = {
|
|---|
| 18 | ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_follows
|
|---|
| 19 | (id, ward_slug, follower_uri, follower_inbox, follower_shared_inbox, follower_name, follower_handle, follower_icon, activity_json, quorum, created_at)
|
|---|
| 20 | VALUES (?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
|
|---|
| 21 | get: db.prepare('SELECT * FROM ap_pending_follows WHERE id = ?'),
|
|---|
| 22 | byWard: db.prepare("SELECT * FROM ap_pending_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
|
|---|
| 23 | approvers: db.prepare('SELECT guardian_uri FROM ap_pending_follow_approvals WHERE follow_id = ?'),
|
|---|
| 24 | approve: db.prepare('INSERT OR IGNORE INTO ap_pending_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
|
|---|
| 25 | setStatus: db.prepare('UPDATE ap_pending_follows SET status = ? WHERE id = ?'),
|
|---|
| 26 | del: db.prepare('DELETE FROM ap_pending_follows WHERE id = ?'),
|
|---|
| 27 | };
|
|---|
| 28 | }
|
|---|
| 29 | return _s;
|
|---|
| 30 | }
|
|---|
| 31 |
|
|---|
| 32 | /** Record a gated follow awaiting guardian approval. */
|
|---|
| 33 | export function recordPending(wardSlug, f) {
|
|---|
| 34 | stmts().ins.run(
|
|---|
| 35 | f.id, wardSlug, f.follower, f.inbox, f.sharedInbox || null,
|
|---|
| 36 | f.name || null, f.handle || null, f.icon || null,
|
|---|
| 37 | JSON.stringify(f.activity || null), f.quorum || 'any',
|
|---|
| 38 | );
|
|---|
| 39 | return stmts().get.get(f.id);
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | export function getPending(id) { return stmts().get.get(id); }
|
|---|
| 43 |
|
|---|
| 44 | /** Pending follows for a local ward (its guardians decide). */
|
|---|
| 45 | export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
|
|---|
| 46 |
|
|---|
| 47 | /**
|
|---|
| 48 | * Record a guardian's decision on a pending follow. Returns
|
|---|
| 49 | * { outcome: 'approved'|'rejected'|'waiting', follow } so the caller can
|
|---|
| 50 | * send the Accept/Reject. A single reject denies; approvals meet the quorum.
|
|---|
| 51 | */
|
|---|
| 52 | export function decide(id, guardianUri, decision, guardiansOfWard) {
|
|---|
| 53 | const follow = stmts().get.get(id);
|
|---|
| 54 | if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
|
|---|
| 55 | stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
|
|---|
| 56 | const rows = db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(id);
|
|---|
| 57 | if (rows.some((r) => r.decision === 'reject')) {
|
|---|
| 58 | stmts().setStatus.run('denied', id);
|
|---|
| 59 | return { outcome: 'rejected', follow };
|
|---|
| 60 | }
|
|---|
| 61 | const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
|
|---|
| 62 | const guardians = (guardiansOfWard || []).filter(Boolean);
|
|---|
| 63 | const enough = follow.quorum === 'all'
|
|---|
| 64 | ? guardians.length > 0 && guardians.every((g) => approvers.has(g))
|
|---|
| 65 | : approvers.size >= 1; // 'any' (default)
|
|---|
| 66 | if (enough) {
|
|---|
| 67 | stmts().setStatus.run('accepted', id);
|
|---|
| 68 | return { outcome: 'approved', follow };
|
|---|
| 69 | }
|
|---|
| 70 | return { outcome: 'waiting', follow };
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| 73 | export function remove(id) { stmts().del.run(id); }
|
|---|
| 74 |
|
|---|
| 75 | // ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
|
|---|
| 76 | // gated follow on a REMOTE ward this account guards, forwarded here as an
|
|---|
| 77 | // Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
|
|---|
| 78 | let _r = null;
|
|---|
| 79 | function rstmts() {
|
|---|
| 80 | if (!_r) {
|
|---|
| 81 | _r = {
|
|---|
| 82 | ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
|
|---|
| 83 | (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
|
|---|
| 84 | VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
|
|---|
| 85 | get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 86 | bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
|
|---|
| 87 | del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
|
|---|
| 88 | };
|
|---|
| 89 | }
|
|---|
| 90 | return _r;
|
|---|
| 91 | }
|
|---|
| 92 |
|
|---|
| 93 | export function recordReview(guardianSlug, r) {
|
|---|
| 94 | rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
|
|---|
| 95 | return rstmts().get.get(guardianSlug, r.id);
|
|---|
| 96 | }
|
|---|
| 97 | export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
|
|---|
| 98 | export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
|
|---|
| 99 | export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
|
|---|
| 100 |
|
|---|
| 101 | export default {
|
|---|
| 102 | recordPending, getPending, listForWard, decide, remove,
|
|---|
| 103 | recordReview, getReview, listReviews, removeReview,
|
|---|
| 104 | };
|
|---|