+164
-64
templates/status.html
+164
-64
templates/status.html
···
247
247
<button id="close-webhook-modal" aria-label="Close">✕</button>
248
248
</div>
249
249
<div class="webhook-modal-body">
250
-
<div class="webhook-intro">
250
+
<div class="webhook-intro" style="margin-bottom:10px;">
251
251
<p>Send signed events when your status changes. Configure a URL that accepts JSON POSTs. We include an HMAC-SHA256 signature in <code>X-Status-Webhook-Signature</code> and a UNIX timestamp in <code>X-Status-Webhook-Timestamp</code>.</p>
252
252
</div>
253
-
<form id="create-webhook-form" class="webhook-form">
254
-
<input type="url" id="wh-url" placeholder="https://example.com/webhook" required />
255
-
<input type="text" id="wh-secret" placeholder="secret (optional – autogenerated if blank)" />
256
-
<input type="text" id="wh-events" placeholder="events (optional, default *) e.g. status.created,status.deleted" />
257
-
<button type="submit">add webhook</button>
253
+
<form id="create-webhook-form" class="webhook-form" aria-label="create webhook">
254
+
<div style="display:flex; flex-direction:column; gap:4px;">
255
+
<input type="url" id="wh-url" placeholder="Webhook URL (https://example.com/webhook)" required />
256
+
<div class="field-help">HTTPS required in production. Local/private hosts allowed only in local dev.</div>
257
+
</div>
258
+
<div style="display:flex; flex-direction:column; gap:4px;">
259
+
<input type="text" id="wh-secret" placeholder="Secret (optional – autogenerated)" />
260
+
<div class="field-help">Used to sign requests with HMAC-SHA256. Reveal only on creation/rotation.</div>
261
+
</div>
262
+
<div style="display:flex; flex-direction:column; gap:4px;">
263
+
<input type="text" id="wh-events" placeholder="Events (optional, default *) e.g. status.created,status.deleted" />
264
+
<div class="field-help">Comma-separated. Supported: <code>status.created</code>, <code>status.deleted</code> or <code>*</code>.</div>
265
+
</div>
266
+
<button type="submit" aria-label="add webhook">add webhook</button>
267
+
<div class="field-help">You can add multiple webhooks. Toggle active, rotate secrets, or delete below.</div>
258
268
</form>
259
269
<div id="webhook-list" class="webhook-list" aria-live="polite"></div>
270
+
271
+
<details class="wh-guide" id="webhook-guide">
272
+
<summary>Integration guide</summary>
273
+
<div class="content">
274
+
<div class="wh-grid">
275
+
<div>
276
+
<h4>Request</h4>
277
+
<ul>
278
+
<li>Method: POST</li>
279
+
<li>Content-Type: application/json</li>
280
+
<li>Header <code>X-Status-Webhook-Timestamp</code>: UNIX seconds</li>
281
+
<li>Header <code>X-Status-Webhook-Signature</code>: <code>sha256=<hex></code></li>
282
+
</ul>
283
+
<h4>Payload</h4>
284
+
<pre><code>{
285
+
"event": "status.created", // or "status.deleted"
286
+
"did": "did:plc:...",
287
+
"handle": null,
288
+
"status": "🙂", // created only
289
+
"text": "in a meeting", // optional
290
+
"uri": "at://...", // record URI
291
+
"since": "2025-09-10T16:00:00Z", // created only
292
+
"expires": null // created only
293
+
}</code></pre>
294
+
</div>
295
+
<div>
296
+
<h4>Verify signature</h4>
297
+
<p>Compute HMAC-SHA256 over <code>timestamp + "." + rawBody</code> using your secret. Compare to header (without the <code>sha256=</code> prefix) with constant-time equality, and reject if timestamp is too old (e.g., > 5 minutes).</p>
298
+
<pre><code>// Node (TypeScript)
299
+
import crypto from 'node:crypto';
300
+
301
+
function verify(req: any, rawBody: Buffer, secret: string): boolean {
302
+
const ts = req.headers['x-status-webhook-timestamp'];
303
+
const sig = String(req.headers['x-status-webhook-signature'] || '').replace(/^sha256=/, '');
304
+
if (!ts || !sig) return false;
305
+
const now = Math.floor(Date.now()/1000);
306
+
if (Math.abs(now - Number(ts)) > 300) return false; // 5m
307
+
const mac = crypto.createHmac('sha256', secret).update(String(ts)).update('.').update(rawBody).digest('hex');
308
+
return crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(sig, 'hex'));
309
+
}
310
+
</code></pre>
311
+
<pre><code>// Rust (axum-ish)
312
+
use hmac::{Hmac, Mac};
313
+
use sha2::Sha256;
314
+
315
+
fn verify(ts: &str, sig_hdr: &str, body: &[u8], secret: &str) -> bool {
316
+
let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr);
317
+
if let Ok(ts_int) = ts.parse::<i64>() {
318
+
if (chrono::Utc::now().timestamp() - ts_int).abs() > 300 { return false; }
319
+
} else { return false; }
320
+
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
321
+
mac.update(ts.as_bytes());
322
+
mac.update(b".");
323
+
mac.update(body);
324
+
let calc = hex::encode(mac.finalize().into_bytes());
325
+
subtle::ConstantTimeEq::ct_eq(calc.as_bytes(), sig.as_bytes()).into()
326
+
}
327
+
</code></pre>
328
+
</div>
329
+
</div>
330
+
</div>
331
+
</details>
260
332
</div>
261
333
</div>
262
334
</div>
···
1348
1420
align-items: center;
1349
1421
justify-content: center;
1350
1422
}
1351
-
.webhook-modal-content {
1352
-
width: min(920px, 95vw);
1353
-
max-height: 90vh;
1354
-
overflow: auto;
1355
-
background: var(--bg, #111);
1356
-
color: var(--text, #fff);
1357
-
border: 1px solid var(--border-color, #2a2a2a);
1358
-
border-radius: 12px;
1359
-
padding: 16px 20px;
1360
-
}
1361
-
.webhook-modal-header {
1362
-
display: flex;
1363
-
align-items: center;
1364
-
justify-content: space-between;
1365
-
margin-bottom: 12px;
1366
-
}
1367
-
.webhook-modal-header h2 { margin: 0; font-size: 18px; }
1368
-
.webhook-modal-header button {
1369
-
background: transparent;
1370
-
color: inherit;
1371
-
border: 1px solid var(--border-color, #2a2a2a);
1372
-
border-radius: 8px;
1373
-
padding: 6px 10px;
1374
-
cursor: pointer;
1375
-
}
1376
-
.webhook-form {
1377
-
display: grid;
1378
-
grid-template-columns: 1fr 200px 1fr auto;
1379
-
gap: 8px;
1380
-
margin-bottom: 16px;
1381
-
}
1382
-
.webhook-form input {
1383
-
background: var(--bg-secondary, #0d0d0d);
1384
-
color: var(--text, #fff);
1385
-
border: 1px solid var(--border-color, #2a2a2a);
1386
-
border-radius: 8px;
1387
-
padding: 10px 12px;
1388
-
}
1389
-
.webhook-form button {
1390
-
background: var(--accent, #1DA1F2);
1391
-
color: #000;
1392
-
border: none;
1393
-
border-radius: 8px;
1394
-
padding: 10px 12px;
1395
-
cursor: pointer;
1396
-
}
1397
-
.webhook-list .item {
1398
-
border: 1px solid var(--border-color, #2a2a2a);
1399
-
border-radius: 8px;
1400
-
padding: 12px;
1401
-
margin-bottom: 10px;
1402
-
display: grid;
1403
-
grid-template-columns: 1fr auto;
1404
-
gap: 8px;
1405
-
}
1423
+
.webhook-modal-content {
1424
+
width: 96vw;
1425
+
height: 92vh;
1426
+
max-width: 1400px;
1427
+
max-height: 92vh;
1428
+
background: var(--bg, #111);
1429
+
color: var(--text, #fff);
1430
+
border: 1px solid var(--border-color, #2a2a2a);
1431
+
border-radius: 12px;
1432
+
display: flex;
1433
+
flex-direction: column;
1434
+
}
1435
+
.webhook-modal-header {
1436
+
display: flex;
1437
+
align-items: center;
1438
+
justify-content: space-between;
1439
+
padding: 16px 20px;
1440
+
border-bottom: 1px solid var(--border-color, #2a2a2a);
1441
+
}
1442
+
.webhook-modal-header h2 { margin: 0; font-size: 20px; }
1443
+
.webhook-modal-header button {
1444
+
background: transparent;
1445
+
color: inherit;
1446
+
border: 1px solid var(--border-color, #2a2a2a);
1447
+
border-radius: 8px;
1448
+
padding: 6px 10px;
1449
+
cursor: pointer;
1450
+
}
1451
+
.webhook-modal-body {
1452
+
padding: 16px 20px;
1453
+
overflow: auto;
1454
+
height: calc(92vh - 60px);
1455
+
}
1456
+
.webhook-form {
1457
+
display: grid;
1458
+
grid-template-columns: 1.2fr 0.8fr 1fr auto;
1459
+
gap: 8px;
1460
+
margin-bottom: 16px;
1461
+
}
1462
+
.webhook-form input {
1463
+
background: var(--bg-secondary, #0d0d0d);
1464
+
color: var(--text, #fff);
1465
+
border: 1px solid var(--border-color, #2a2a2a);
1466
+
border-radius: 8px;
1467
+
padding: 10px 12px;
1468
+
}
1469
+
.webhook-form button {
1470
+
background: var(--accent, #1DA1F2);
1471
+
color: #000;
1472
+
border: none;
1473
+
border-radius: 8px;
1474
+
padding: 10px 12px;
1475
+
cursor: pointer;
1476
+
}
1477
+
.field-help { font-size: 12px; opacity: 0.8; margin-top: 2px; grid-column: 1 / -1; }
1478
+
.webhook-list .item {
1479
+
border: 1px solid var(--border-color, #2a2a2a);
1480
+
border-radius: 8px;
1481
+
padding: 12px;
1482
+
margin-bottom: 10px;
1483
+
display: grid;
1484
+
grid-template-columns: 1fr auto;
1485
+
gap: 8px;
1486
+
}
1406
1487
.webhook-list .meta { font-size: 12px; opacity: 0.8; }
1407
1488
.webhook-actions { display: flex; gap: 8px; align-items: center; }
1408
1489
.webhook-actions button {
···
1413
1494
padding: 6px 10px;
1414
1495
cursor: pointer;
1415
1496
}
1416
-
.webhook-actions .danger { border-color: #803; color: #f77; }
1417
-
.webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
1418
-
.webhook-active input { transform: translateY(1px); }
1497
+
.webhook-actions .danger { border-color: #803; color: #f77; }
1498
+
.webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
1499
+
.webhook-active input { transform: translateY(1px); }
1500
+
1501
+
/* Collapsible guide */
1502
+
.wh-guide {
1503
+
margin-top: 20px;
1504
+
border: 1px solid var(--border-color, #2a2a2a);
1505
+
border-radius: 10px;
1506
+
overflow: hidden;
1507
+
}
1508
+
.wh-guide summary {
1509
+
padding: 12px 14px;
1510
+
cursor: pointer;
1511
+
background: var(--bg-secondary, #0f0f0f);
1512
+
font-weight: 600;
1513
+
outline: none;
1514
+
}
1515
+
.wh-guide .content { padding: 14px; }
1516
+
.wh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
1517
+
.wh-grid pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; }
1518
+
@media (max-width: 900px) { .wh-grid { grid-template-columns: 1fr; } }
1419
1519
</style>
1420
1520
1421
1521
<script>