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.
- package/examples/anthropic-tools-agent.js +66 -0
- package/examples/openai-tools-agent.js +69 -0
- 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 +12 -0
- package/server/middleware/rateLimits.js +23 -0
- package/server/migrations/024_site_revocations.sql +69 -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/revocations.js +378 -0
- package/server/services/transactions.js +5 -1
|
@@ -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
|
+
};
|