web-agent-bridge 3.10.1 → 3.13.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Site Revocations API (v3.11.0)
3
+ *
4
+ * • Public — anyone can read the transparency log + check a domain's status.
5
+ * • Owner — site owners can self-disable, reinstate their own disable,
6
+ * and submit appeals against suspensions / revocations.
7
+ * • Admin — open suspensions/revocations, decide appeals, manual reinstate.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const express = require('express');
13
+ const router = express.Router();
14
+
15
+ const { authenticateToken } = require('../middleware/auth');
16
+ const { authenticateAdmin } = require('../middleware/adminAuth');
17
+ const { db } = require('../models/db');
18
+ const rev = require('../services/revocations');
19
+
20
+ function _handle(res, fn) {
21
+ try {
22
+ const out = fn();
23
+ res.json({ ok: true, data: out });
24
+ } catch (e) {
25
+ const status = e.statusCode || 500;
26
+ res.status(status).json({ ok: false, error: e.code || 'internal_error', message: e.message });
27
+ }
28
+ }
29
+
30
+ // ─── PUBLIC ──────────────────────────────────────────────────────────────────
31
+
32
+ /** GET /api/revocations/transparency — public log */
33
+ router.get('/transparency', (req, res) => {
34
+ _handle(res, () => rev.listPublic({
35
+ limit: parseInt(req.query.limit, 10) || 50,
36
+ offset: parseInt(req.query.offset, 10) || 0,
37
+ }));
38
+ });
39
+
40
+ /** GET /api/revocations/status?domain=example.com — public per-domain status */
41
+ router.get('/status', (req, res) => {
42
+ const domain = String(req.query.domain || '').trim().toLowerCase();
43
+ if (!domain) return res.status(400).json({ ok: false, error: 'domain required' });
44
+ const r = rev.getActiveByDomain(domain);
45
+ res.json({
46
+ ok: true,
47
+ data: {
48
+ domain,
49
+ revoked: !!r,
50
+ revocation: r ? {
51
+ id: r.id,
52
+ type: r.type,
53
+ reason_code: r.reason_code,
54
+ reason_text: r.reason_text,
55
+ evidence_url: r.evidence_url,
56
+ decided_at: r.decided_at,
57
+ appeal_deadline: r.appeal_deadline,
58
+ status: r.status,
59
+ } : null,
60
+ },
61
+ });
62
+ });
63
+
64
+ // ─── OWNER (authenticated user) ──────────────────────────────────────────────
65
+
66
+ /** POST /api/revocations/sites/:siteId/disable — owner self-disable */
67
+ router.post('/sites/:siteId/disable', authenticateToken, express.json({ limit: '8kb' }), (req, res) => {
68
+ _handle(res, () => {
69
+ const site = db.prepare(`SELECT * FROM sites WHERE id = ?`).get(req.params.siteId);
70
+ if (!site) { const e = new Error('site not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
71
+ if (site.user_id !== req.user.id) {
72
+ const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e;
73
+ }
74
+ return rev.openRevocation({
75
+ siteId: site.id,
76
+ type: 'owner_disable',
77
+ reasonCode: 'owner_request',
78
+ reasonText: (req.body.reason_text && String(req.body.reason_text).trim())
79
+ || 'Owner requested self-disable.',
80
+ decidedBy: `owner:${req.user.id}`,
81
+ });
82
+ });
83
+ });
84
+
85
+ /** POST /api/revocations/:id/appeal — owner appeal */
86
+ router.post('/:id/appeal', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
87
+ _handle(res, () => rev.submitAppeal({
88
+ revocationId: req.params.id,
89
+ ownerUserId: req.user.id,
90
+ statement: String(req.body.statement || ''),
91
+ remediationProof: req.body.remediation_proof || null,
92
+ }));
93
+ });
94
+
95
+ /** POST /api/revocations/:id/reinstate — owner re-enables their own disable */
96
+ router.post('/:id/reinstate', authenticateToken, express.json({ limit: '4kb' }), (req, res) => {
97
+ _handle(res, () => {
98
+ const r = rev.getById(req.params.id);
99
+ if (!r) { const e = new Error('not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
100
+ if (r.type !== 'owner_disable') {
101
+ const e = new Error('only owner_disable can be self-reinstated'); e.statusCode = 403; e.code = 'forbidden'; throw e;
102
+ }
103
+ const site = db.prepare(`SELECT * FROM sites WHERE id = ?`).get(r.site_id);
104
+ if (!site || site.user_id !== req.user.id) {
105
+ const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e;
106
+ }
107
+ return rev.reinstate({
108
+ revocationId: r.id, actorId: req.user.id, actorType: 'user',
109
+ reason: req.body.reason || 'owner_reinstated',
110
+ });
111
+ });
112
+ });
113
+
114
+ /** GET /api/revocations/:id — owner/admin can view */
115
+ router.get('/:id', authenticateToken, (req, res) => {
116
+ const r = rev.getById(req.params.id);
117
+ if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
118
+ const site = db.prepare(`SELECT user_id FROM sites WHERE id = ?`).get(r.site_id);
119
+ if (!site || site.user_id !== req.user.id) {
120
+ return res.status(403).json({ ok: false, error: 'forbidden' });
121
+ }
122
+ res.json({ ok: true, data: { ...r, appeal: rev.getAppeal(r.id) } });
123
+ });
124
+
125
+ // ─── ADMIN ───────────────────────────────────────────────────────────────────
126
+
127
+ /** GET /api/revocations/admin/list */
128
+ router.get('/admin/list', authenticateAdmin, (req, res) => {
129
+ _handle(res, () => rev.listAdmin({
130
+ status: req.query.status || undefined,
131
+ type: req.query.type || undefined,
132
+ limit: parseInt(req.query.limit, 10) || 100,
133
+ offset: parseInt(req.query.offset, 10) || 0,
134
+ }));
135
+ });
136
+
137
+ /** POST /api/revocations/admin/open */
138
+ router.post('/admin/open', authenticateAdmin, express.json({ limit: '16kb' }), (req, res) => {
139
+ _handle(res, () => {
140
+ const b = req.body || {};
141
+ let siteId = b.site_id;
142
+ if (!siteId && b.domain) {
143
+ const site = db.prepare(`SELECT id FROM sites WHERE domain = ? LIMIT 1`).get(String(b.domain).toLowerCase());
144
+ siteId = site ? site.id : null;
145
+ }
146
+ if (!siteId) { const e = new Error('site_id or domain required'); e.statusCode = 400; e.code = 'bad_request'; throw e; }
147
+ return rev.openRevocation({
148
+ siteId,
149
+ type: b.type || 'suspended',
150
+ reasonCode: b.reason_code,
151
+ reasonText: b.reason_text,
152
+ evidenceUrl: b.evidence_url || null,
153
+ decidedBy: `admin:${req.admin.id}`,
154
+ });
155
+ });
156
+ });
157
+
158
+ /** POST /api/revocations/admin/:id/decide — decide an appeal */
159
+ router.post('/admin/:id/decide', authenticateAdmin, express.json({ limit: '8kb' }), (req, res) => {
160
+ _handle(res, () => rev.decideAppeal({
161
+ revocationId: req.params.id,
162
+ decision: req.body.decision,
163
+ decisionReason: req.body.decision_reason || null,
164
+ adminId: req.admin.id,
165
+ }));
166
+ });
167
+
168
+ /** POST /api/revocations/admin/:id/reinstate — manual reinstate */
169
+ router.post('/admin/:id/reinstate', authenticateAdmin, express.json({ limit: '4kb' }), (req, res) => {
170
+ _handle(res, () => rev.reinstate({
171
+ revocationId: req.params.id,
172
+ actorId: req.admin.id,
173
+ actorType: 'admin',
174
+ reason: req.body.reason || null,
175
+ }));
176
+ });
177
+
178
+ /** POST /api/revocations/admin/sweep — manually trigger expired-appeal sweep */
179
+ router.post('/admin/sweep', authenticateAdmin, (req, res) => {
180
+ _handle(res, () => ({ swept: rev.sweepExpired() }));
181
+ });
182
+
183
+ module.exports = router;
@@ -25,6 +25,7 @@ const express = require('express');
25
25
  const router = express.Router();
26
26
 
27
27
  const { authenticateToken } = require('../middleware/auth');
28
+ const { atpStrictLimiter } = require('../middleware/rateLimits');
28
29
  const transactions = require('../services/transactions');
29
30
  const { db } = require('../models/db');
30
31
 
@@ -63,7 +64,7 @@ function send(res, fn) {
63
64
  }
64
65
 
65
66
  // ─── Intents ─────────────────────────────────────────────────────────────────
66
- router.post('/intents', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
67
+ router.post('/intents', atpStrictLimiter, authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
67
68
  const q = checkDailyIntentQuota(req.user.id);
68
69
  if (!q.ok) {
69
70
  return res.status(429).json({
@@ -125,7 +126,7 @@ function loadTxOwned(txId, userId) {
125
126
  return { tx, intent };
126
127
  }
127
128
 
128
- router.post('/transactions', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
129
+ router.post('/transactions', atpStrictLimiter, authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
129
130
  send(res, () => {
130
131
  const intent = loadIntentAuthorized(req.body.intent_id, req.user.id);
131
132
  const idem = req.headers['idempotency-key'] || req.body.idempotency_key;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * RFC 8785 — JSON Canonicalization Scheme (JCS)
3
+ * ───────────────────────────────────────────────────────────────────────────
4
+ * Produces a deterministic byte sequence for any JSON-serialisable value,
5
+ * suitable for hashing or signing. Implements:
6
+ *
7
+ * • Object keys sorted lexicographically by UTF-16 code units (per RFC 8785 §3.2.3).
8
+ * • Numbers serialised per ES2017 ECMAScript ToString (RFC 8785 §3.2.2.2),
9
+ * with finite-only checks (Infinity / NaN are rejected — RFC 8259 §6).
10
+ * • Strings escaped with the minimal RFC 8259 §7 form (control chars + \" + \\).
11
+ * • Booleans / null encoded as `true` / `false` / `null`.
12
+ * • Arrays preserve element order.
13
+ *
14
+ * This is intentionally dependency-free so it can be used by signing paths,
15
+ * audit-log HMAC chains, and ATP receipt verification without pulling extra
16
+ * packages. Performance is O(n log n) over object keys.
17
+ *
18
+ * Anti-features (deliberate):
19
+ * • Does NOT support `undefined`, functions, symbols, BigInt — throws.
20
+ * • Does NOT escape non-ASCII; output is valid UTF-8 by construction.
21
+ * • Does NOT pretty-print.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const HEX = '0123456789abcdef';
27
+
28
+ function _escapeString(s) {
29
+ // RFC 8785 §3.2.2.1 → RFC 8259 §7: minimal escaping.
30
+ let out = '"';
31
+ for (let i = 0; i < s.length; i++) {
32
+ const c = s.charCodeAt(i);
33
+ if (c === 0x22) out += '\\"';
34
+ else if (c === 0x5c) out += '\\\\';
35
+ else if (c === 0x08) out += '\\b';
36
+ else if (c === 0x09) out += '\\t';
37
+ else if (c === 0x0a) out += '\\n';
38
+ else if (c === 0x0c) out += '\\f';
39
+ else if (c === 0x0d) out += '\\r';
40
+ else if (c < 0x20) {
41
+ out += '\\u00' + HEX[(c >> 4) & 0xf] + HEX[c & 0xf];
42
+ } else {
43
+ out += s[i];
44
+ }
45
+ }
46
+ return out + '"';
47
+ }
48
+
49
+ function _serializeNumber(n) {
50
+ // RFC 8785 §3.2.2.2: ECMAScript ToString. Reject non-finite per RFC 8259.
51
+ if (!Number.isFinite(n)) {
52
+ throw new TypeError(`canonical-json: non-finite number not allowed (${n})`);
53
+ }
54
+ if (n === 0) return '0'; // collapses -0 → "0"
55
+ return String(n);
56
+ }
57
+
58
+ function canonicalize(value) {
59
+ if (value === null) return 'null';
60
+
61
+ const t = typeof value;
62
+
63
+ if (t === 'boolean') return value ? 'true' : 'false';
64
+ if (t === 'number') return _serializeNumber(value);
65
+ if (t === 'string') return _escapeString(value);
66
+
67
+ if (t === 'bigint' || t === 'function' || t === 'symbol' || t === 'undefined') {
68
+ throw new TypeError(`canonical-json: unsupported type ${t}`);
69
+ }
70
+
71
+ if (Array.isArray(value)) {
72
+ let out = '[';
73
+ for (let i = 0; i < value.length; i++) {
74
+ if (i > 0) out += ',';
75
+ const v = value[i];
76
+ out += v === undefined ? 'null' : canonicalize(v); // align with JSON.stringify
77
+ }
78
+ return out + ']';
79
+ }
80
+
81
+ // Plain object — sort keys by UTF-16 code units (default String sort).
82
+ if (t === 'object') {
83
+ // Strip undefined values (per JSON spec) before sorting.
84
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
85
+ let out = '{';
86
+ for (let i = 0; i < keys.length; i++) {
87
+ if (i > 0) out += ',';
88
+ const k = keys[i];
89
+ out += _escapeString(k) + ':' + canonicalize(value[k]);
90
+ }
91
+ return out + '}';
92
+ }
93
+
94
+ throw new TypeError(`canonical-json: unsupported value ${value}`);
95
+ }
96
+
97
+ /** Convenience: return a SHA-256 hex digest over the canonical form. */
98
+ function canonicalDigest(value, algo = 'sha256') {
99
+ const crypto = require('crypto');
100
+ return crypto.createHash(algo).update(canonicalize(value), 'utf8').digest('hex');
101
+ }
102
+
103
+ module.exports = { canonicalize, canonicalDigest };
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Site Revocations & Appeals (v3.11.0)
3
+ * ───────────────────────────────────────────────────────────────────────────
4
+ * Governance primitive for transparently disabling WAB-registered domains
5
+ * with a 1-week owner appeal window and a public transparency log.
6
+ *
7
+ * Authority tiers:
8
+ *
9
+ * 1. owner_disable
10
+ * The site owner self-pauses their domain. Instantaneous, no appeal
11
+ * window (they can re-enable themselves at any time by calling
12
+ * reinstate() on their own revocation).
13
+ *
14
+ * 2. suspended
15
+ * Platform-issued temporary suspension (community report, automated
16
+ * rule, partner takedown). Opens a 7-day appeal window during which
17
+ * the owner may submit a rebuttal + remediation proof. After the
18
+ * window the revocation auto-finalises unless overturned.
19
+ *
20
+ * 3. revoked
21
+ * Permanent revocation. Reserved for hard breaches (proven fraud,
22
+ * malware distribution, court order). Still gets a 7-day appeal —
23
+ * due process matters more than throughput.
24
+ *
25
+ * Every decision is Ed25519-signed by the operator key (if configured) and
26
+ * mirrored into `audit_log` for HMAC-chained tamper-evidence.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const crypto = require('crypto');
32
+ const { db } = require('../models/db');
33
+ const { canonicalize } = require('./canonical-json');
34
+ const { auditLog } = require('./security');
35
+
36
+ const APPEAL_WINDOW_DAYS = Number(process.env.WAB_REVOCATION_APPEAL_DAYS || 7);
37
+ const APPEAL_WINDOW_MS = APPEAL_WINDOW_DAYS * 24 * 60 * 60 * 1000;
38
+ const OPERATOR_KEY_B64 = process.env.WAB_OPERATOR_ED25519_PRIV || '';
39
+
40
+ const VALID_TYPES = new Set(['owner_disable', 'suspended', 'revoked']);
41
+ const REASON_CODES = new Set([
42
+ 'fraud', 'abuse', 'policy_breach', 'malware', 'court_order',
43
+ 'owner_request', 'security_incident', 'spam', 'impersonation', 'other',
44
+ ]);
45
+
46
+ function _ulid(prefix) {
47
+ return `${prefix}_${Date.now().toString(36)}${crypto.randomBytes(8).toString('hex')}`;
48
+ }
49
+
50
+ function _signDecision(payload) {
51
+ if (!OPERATOR_KEY_B64) return null;
52
+ try {
53
+ const keyDer = Buffer.from(OPERATOR_KEY_B64, 'base64');
54
+ const key = crypto.createPrivateKey({ key: keyDer, format: 'der', type: 'pkcs8' });
55
+ const sig = crypto.sign(null, Buffer.from(canonicalize(payload), 'utf8'), key);
56
+ return sig.toString('base64');
57
+ } catch (e) {
58
+ console.warn('[revocations] signature failed (non-fatal):', e.message);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function _findSiteByDomain(domain) {
64
+ return db.prepare(`SELECT * FROM sites WHERE domain = ? LIMIT 1`).get(domain);
65
+ }
66
+
67
+ function _findSiteById(id) {
68
+ return db.prepare(`SELECT * FROM sites WHERE id = ? LIMIT 1`).get(id);
69
+ }
70
+
71
+ /**
72
+ * Open a new revocation against a site.
73
+ *
74
+ * @param {object} args
75
+ * @param {string} args.siteId
76
+ * @param {'owner_disable'|'suspended'|'revoked'} args.type
77
+ * @param {string} args.reasonCode
78
+ * @param {string} args.reasonText
79
+ * @param {string} args.decidedBy e.g. 'admin:42', 'owner:user_id', 'system:rule_x'
80
+ * @param {string} [args.evidenceUrl]
81
+ * @returns the inserted row
82
+ */
83
+ function openRevocation({ siteId, type, reasonCode, reasonText, decidedBy, evidenceUrl }) {
84
+ if (!VALID_TYPES.has(type)) throw Object.assign(new Error('invalid type'), { code: 'bad_type' });
85
+ if (!REASON_CODES.has(reasonCode)) throw Object.assign(new Error('invalid reason_code'), { code: 'bad_reason' });
86
+ if (!reasonText || reasonText.length < 8) {
87
+ throw Object.assign(new Error('reason_text must be at least 8 chars'), { code: 'bad_reason_text' });
88
+ }
89
+ const site = _findSiteById(siteId);
90
+ if (!site) throw Object.assign(new Error('site not found'), { code: 'not_found', statusCode: 404 });
91
+
92
+ // Block opening a duplicate active revocation of the same kind.
93
+ const existing = db.prepare(`
94
+ SELECT id FROM site_revocations
95
+ WHERE site_id = ? AND status IN ('pending_appeal','appealed','final')
96
+ LIMIT 1
97
+ `).get(siteId);
98
+ if (existing && type !== 'owner_disable') {
99
+ throw Object.assign(new Error('site already has an active revocation'),
100
+ { code: 'already_revoked', statusCode: 409 });
101
+ }
102
+
103
+ const id = _ulid('rev');
104
+ const now = new Date();
105
+ const appealDeadline = type === 'owner_disable'
106
+ ? null
107
+ : new Date(now.getTime() + APPEAL_WINDOW_MS).toISOString();
108
+
109
+ const payload = {
110
+ id, site_id: siteId, domain: site.domain, type,
111
+ reason_code: reasonCode, reason_text: reasonText,
112
+ evidence_url: evidenceUrl || null,
113
+ decided_by: decidedBy, decided_at: now.toISOString(),
114
+ appeal_deadline: appealDeadline,
115
+ };
116
+ const signature = _signDecision(payload);
117
+
118
+ db.prepare(`
119
+ INSERT INTO site_revocations
120
+ (id, site_id, domain, type, reason_code, reason_text, evidence_url,
121
+ decided_by, decided_at, appeal_deadline, status, signature)
122
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
+ `).run(
124
+ id, siteId, site.domain, type, reasonCode, reasonText, evidenceUrl || null,
125
+ decidedBy, now.toISOString(), appealDeadline,
126
+ type === 'owner_disable' ? 'final' : 'pending_appeal',
127
+ signature,
128
+ );
129
+
130
+ // Flip the site itself to inactive so downstream lookups stop trusting it.
131
+ db.prepare(`UPDATE sites SET active = 0 WHERE id = ?`).run(siteId);
132
+
133
+ auditLog({
134
+ actorType: decidedBy.startsWith('admin:') ? 'admin' : decidedBy.startsWith('owner:') ? 'user' : 'system',
135
+ actorId: decidedBy.split(':')[1] || decidedBy,
136
+ action: 'site_revocation_opened',
137
+ resource: 'site', resourceId: siteId,
138
+ details: { id, type, reason_code: reasonCode, domain: site.domain },
139
+ severity: type === 'revoked' ? 'critical' : 'warning',
140
+ });
141
+
142
+ return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(id);
143
+ }
144
+
145
+ /**
146
+ * Submit an owner appeal. Only the site owner may call this, and only
147
+ * within the appeal window. Re-submitting overwrites the open appeal.
148
+ */
149
+ function submitAppeal({ revocationId, ownerUserId, statement, remediationProof }) {
150
+ if (!statement || statement.length < 16) {
151
+ throw Object.assign(new Error('statement must be at least 16 chars'),
152
+ { code: 'bad_statement', statusCode: 400 });
153
+ }
154
+ const rev = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
155
+ if (!rev) throw Object.assign(new Error('revocation not found'), { code: 'not_found', statusCode: 404 });
156
+
157
+ const site = _findSiteById(rev.site_id);
158
+ if (!site || site.user_id !== ownerUserId) {
159
+ throw Object.assign(new Error('forbidden'), { code: 'forbidden', statusCode: 403 });
160
+ }
161
+ if (rev.type === 'owner_disable') {
162
+ throw Object.assign(new Error('owner_disable cannot be appealed (use reinstate)'),
163
+ { code: 'not_appealable', statusCode: 400 });
164
+ }
165
+ if (!['pending_appeal', 'appealed'].includes(rev.status)) {
166
+ throw Object.assign(new Error(`revocation in '${rev.status}' is not appealable`),
167
+ { code: 'not_appealable', statusCode: 409 });
168
+ }
169
+ if (rev.appeal_deadline && new Date(rev.appeal_deadline).getTime() < Date.now()) {
170
+ // Auto-finalise stale appeals lazily.
171
+ db.prepare(`UPDATE site_revocations SET status='final', finalized_at=datetime('now'), updated_at=datetime('now') WHERE id=?`).run(revocationId);
172
+ throw Object.assign(new Error('appeal window expired'),
173
+ { code: 'appeal_expired', statusCode: 410 });
174
+ }
175
+
176
+ const existing = db.prepare(`SELECT id FROM revocation_appeals WHERE revocation_id = ?`).get(revocationId);
177
+ if (existing) {
178
+ db.prepare(`
179
+ UPDATE revocation_appeals
180
+ SET statement = ?, remediation_proof = ?, submitted_at = datetime('now'),
181
+ decision = NULL, decision_reason = NULL, decided_by = NULL, decided_at = NULL
182
+ WHERE revocation_id = ?
183
+ `).run(statement, remediationProof || null, revocationId);
184
+ } else {
185
+ db.prepare(`
186
+ INSERT INTO revocation_appeals (id, revocation_id, owner_user_id, statement, remediation_proof)
187
+ VALUES (?, ?, ?, ?, ?)
188
+ `).run(_ulid('app'), revocationId, ownerUserId, statement, remediationProof || null);
189
+ }
190
+
191
+ db.prepare(`UPDATE site_revocations SET status='appealed', updated_at=datetime('now') WHERE id=?`).run(revocationId);
192
+
193
+ auditLog({
194
+ actorType: 'user', actorId: String(ownerUserId),
195
+ action: 'revocation_appeal_submitted',
196
+ resource: 'site_revocation', resourceId: revocationId,
197
+ details: { domain: rev.domain },
198
+ });
199
+
200
+ return db.prepare(`
201
+ SELECT a.*, r.domain, r.type AS revocation_type
202
+ FROM revocation_appeals a
203
+ JOIN site_revocations r ON r.id = a.revocation_id
204
+ WHERE a.revocation_id = ?
205
+ `).get(revocationId);
206
+ }
207
+
208
+ /**
209
+ * Admin decision on an appeal.
210
+ * decision = 'upheld' → site reinstated
211
+ * decision = 'rejected' → revocation finalised
212
+ */
213
+ function decideAppeal({ revocationId, decision, decisionReason, adminId }) {
214
+ if (!['upheld', 'rejected'].includes(decision)) {
215
+ throw Object.assign(new Error('decision must be upheld|rejected'),
216
+ { code: 'bad_decision', statusCode: 400 });
217
+ }
218
+ const rev = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
219
+ if (!rev) throw Object.assign(new Error('revocation not found'), { code: 'not_found', statusCode: 404 });
220
+ const appeal = db.prepare(`SELECT * FROM revocation_appeals WHERE revocation_id = ?`).get(revocationId);
221
+ if (!appeal) throw Object.assign(new Error('no appeal to decide'), { code: 'no_appeal', statusCode: 404 });
222
+ if (rev.status !== 'appealed') {
223
+ throw Object.assign(new Error(`revocation in '${rev.status}' has no open appeal`),
224
+ { code: 'no_open_appeal', statusCode: 409 });
225
+ }
226
+
227
+ db.prepare(`
228
+ UPDATE revocation_appeals
229
+ SET decision = ?, decision_reason = ?, decided_by = ?, decided_at = datetime('now')
230
+ WHERE revocation_id = ?
231
+ `).run(decision, decisionReason || null, String(adminId), revocationId);
232
+
233
+ if (decision === 'upheld') {
234
+ db.prepare(`
235
+ UPDATE site_revocations
236
+ SET status='overturned', reinstated_at=datetime('now'), reinstated_by=?, updated_at=datetime('now')
237
+ WHERE id=?
238
+ `).run(`admin:${adminId}`, revocationId);
239
+ db.prepare(`UPDATE sites SET active = 1 WHERE id = ?`).run(rev.site_id);
240
+ } else {
241
+ db.prepare(`
242
+ UPDATE site_revocations
243
+ SET status='final', finalized_at=datetime('now'), updated_at=datetime('now')
244
+ WHERE id=?
245
+ `).run(revocationId);
246
+ }
247
+
248
+ auditLog({
249
+ actorType: 'admin', actorId: String(adminId),
250
+ action: 'revocation_appeal_decided',
251
+ resource: 'site_revocation', resourceId: revocationId,
252
+ details: { decision, domain: rev.domain },
253
+ severity: decision === 'rejected' ? 'warning' : 'info',
254
+ });
255
+
256
+ return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
257
+ }
258
+
259
+ /**
260
+ * Manually reinstate (governance override or owner re-enabling their own disable).
261
+ */
262
+ function reinstate({ revocationId, actorId, actorType = 'admin', reason }) {
263
+ const rev = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
264
+ if (!rev) throw Object.assign(new Error('revocation not found'), { code: 'not_found', statusCode: 404 });
265
+ if (rev.status === 'reinstated' || rev.status === 'overturned') {
266
+ return rev; // idempotent
267
+ }
268
+
269
+ db.prepare(`
270
+ UPDATE site_revocations
271
+ SET status='reinstated', reinstated_at=datetime('now'), reinstated_by=?, updated_at=datetime('now')
272
+ WHERE id=?
273
+ `).run(`${actorType}:${actorId}`, revocationId);
274
+ db.prepare(`UPDATE sites SET active = 1 WHERE id = ?`).run(rev.site_id);
275
+
276
+ auditLog({
277
+ actorType, actorId: String(actorId),
278
+ action: 'site_revocation_reinstated',
279
+ resource: 'site_revocation', resourceId: revocationId,
280
+ details: { domain: rev.domain, reason: reason || null },
281
+ });
282
+
283
+ return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
284
+ }
285
+
286
+ /**
287
+ * Lazy sweep: any 'pending_appeal' whose deadline elapsed → 'final'.
288
+ * Called from periodic worker AND from getActiveByDomain to keep state honest.
289
+ */
290
+ function sweepExpired() {
291
+ const r = db.prepare(`
292
+ UPDATE site_revocations
293
+ SET status='final', finalized_at=datetime('now'), updated_at=datetime('now')
294
+ WHERE status='pending_appeal'
295
+ AND appeal_deadline IS NOT NULL
296
+ AND datetime(appeal_deadline) <= datetime('now')
297
+ `).run();
298
+ return r.changes || 0;
299
+ }
300
+
301
+ /** Returns the active (blocking) revocation for a domain, or null. */
302
+ function getActiveByDomain(domain) {
303
+ sweepExpired();
304
+ return db.prepare(`
305
+ SELECT * FROM site_revocations
306
+ WHERE domain = ? AND status IN ('pending_appeal','appealed','final')
307
+ ORDER BY decided_at DESC LIMIT 1
308
+ `).get(domain) || null;
309
+ }
310
+
311
+ /** Public transparency feed (newest first, redacts internal IDs). */
312
+ function listPublic({ limit = 50, offset = 0 } = {}) {
313
+ const rows = db.prepare(`
314
+ SELECT id, domain, type, reason_code, reason_text, evidence_url,
315
+ decided_at, appeal_deadline, status, finalized_at, reinstated_at
316
+ FROM site_revocations
317
+ WHERE type != 'owner_disable'
318
+ ORDER BY decided_at DESC LIMIT ? OFFSET ?
319
+ `).all(Math.min(limit, 200), Math.max(offset, 0));
320
+ return rows;
321
+ }
322
+
323
+ /** Admin: full listing with optional filters. */
324
+ function listAdmin({ status, type, limit = 100, offset = 0 } = {}) {
325
+ const where = [];
326
+ const params = [];
327
+ if (status) { where.push('status = ?'); params.push(status); }
328
+ if (type) { where.push('type = ?'); params.push(type); }
329
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
330
+ params.push(Math.min(limit, 500), Math.max(offset, 0));
331
+ return db.prepare(`
332
+ SELECT * FROM site_revocations
333
+ ${clause}
334
+ ORDER BY decided_at DESC LIMIT ? OFFSET ?
335
+ `).all(...params);
336
+ }
337
+
338
+ function getById(id) {
339
+ return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(id);
340
+ }
341
+
342
+ function getAppeal(revocationId) {
343
+ return db.prepare(`SELECT * FROM revocation_appeals WHERE revocation_id = ?`).get(revocationId);
344
+ }
345
+
346
+ /** Optional background worker — env-gated. */
347
+ function startPeriodicSweep() {
348
+ const hours = Number(process.env.WAB_REVOCATION_SWEEP_INTERVAL_HOURS || 0);
349
+ if (!hours || hours < 0.1) return null;
350
+ const ms = hours * 60 * 60 * 1000;
351
+ const t = setInterval(() => {
352
+ try {
353
+ const n = sweepExpired();
354
+ if (n) console.log(`[revocations] swept ${n} expired appeal windows`);
355
+ } catch (e) {
356
+ console.error('[revocations] sweep failed:', e.message);
357
+ }
358
+ }, ms);
359
+ t.unref?.();
360
+ return { intervalHours: hours };
361
+ }
362
+
363
+ module.exports = {
364
+ openRevocation,
365
+ submitAppeal,
366
+ decideAppeal,
367
+ reinstate,
368
+ sweepExpired,
369
+ getActiveByDomain,
370
+ listPublic,
371
+ listAdmin,
372
+ getById,
373
+ getAppeal,
374
+ startPeriodicSweep,
375
+ APPEAL_WINDOW_DAYS,
376
+ VALID_TYPES,
377
+ REASON_CODES,
378
+ };