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.
- package/package.json +1 -1
- package/public/revocations.html +151 -0
- package/sdk/index.d.ts +13 -0
- package/sdk/index.js +11 -0
- package/sdk/system-prompt.js +91 -0
- package/server/index.js +18 -0
- package/server/middleware/rateLimits.js +23 -0
- package/server/migrations/024_site_revocations.sql +69 -0
- package/server/routes/admin.js +18 -0
- package/server/routes/agent-prompt.js +27 -0
- package/server/routes/discovery.js +32 -2
- package/server/routes/revocations.js +183 -0
- package/server/routes/transactions.js +3 -2
- package/server/services/canonical-json.js +103 -0
- package/server/services/commission-billing.js +279 -0
- package/server/services/revocations.js +378 -0
- package/server/services/stripe.js +12 -0
- package/server/services/transactions.js +5 -1
|
@@ -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
|
-
|
|
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
|
}
|