web-agent-bridge 3.10.0 → 3.12.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,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
+ };
@@ -151,6 +151,18 @@ function handleWebhookEvent(event) {
151
151
 
152
152
  case 'invoice.payment_succeeded': {
153
153
  const invoice = event.data.object;
154
+
155
+ // ── ATP merchant commission invoice paid? Flip rows to 'collected'. ──
156
+ try {
157
+ if (invoice.metadata && invoice.metadata.wab_kind === 'atp_commission_batch') {
158
+ const billing = require('./commission-billing');
159
+ const changed = billing.onStripeInvoicePaid(invoice);
160
+ console.log(`[atp] commission invoice ${invoice.id} paid; ${changed} rows → collected`);
161
+ }
162
+ } catch (e) {
163
+ console.error('[atp] commission invoice paid handler failed (non-fatal):', e.message);
164
+ }
165
+
154
166
  if (invoice.subscription) {
155
167
  const sub = getStripeSubscriptionBySubId(invoice.subscription);
156
168
  if (sub) {
@@ -145,7 +145,11 @@ function authorizeIntent(intentId, { userId }) {
145
145
  if (!intent) throw notFound('intent not found');
146
146
  if (intent.user_id !== userId) throw forbidden('not your intent');
147
147
  if (intent.status !== 'draft') throw conflict(`cannot authorize intent in status '${intent.status}'`, 'invalid_state');
148
- if (new Date(intent.expires_at).getTime() < Date.now()) {
148
+ // v3.11.0: allow a small clock-skew tolerance so clients on slightly drifted
149
+ // clocks aren't rejected. Default \u00b160s; override via WAB_CLOCK_SKEW_TOLERANCE_SEC.
150
+ const skewSec = Number(process.env.WAB_CLOCK_SKEW_TOLERANCE_SEC || 60);
151
+ const expiresAt = new Date(intent.expires_at).getTime();
152
+ if (expiresAt + (skewSec * 1000) < Date.now()) {
149
153
  db.prepare("UPDATE atp_intents SET status='expired', updated_at=? WHERE id=?").run(nowIso(), intentId);
150
154
  throw conflict('intent expired before authorization', 'expired');
151
155
  }