Skip to content

Commit 9dbaae9

Browse files
committed
feat(dev,html-app): allow removing expired webhooks from the webhooks list
1 parent c59bd53 commit 9dbaae9

1 file changed

Lines changed: 142 additions & 10 deletions

File tree

dev/tools/webhook.html

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,36 @@
2424
// KV + crypto primitives come from the sandbox-injected `globalThis.secutils`
2525
// (see src/js_runtime/op_responder_kv.rs and op_crypto_seal.rs). Binary values
2626
// cross the boundary as base64url strings.
27+
//
28+
// -----------------------------------------------------------------------------
29+
// TESTING THE EXPIRED TAKE-OVER (handy reference)
30+
// -----------------------------------------------------------------------------
31+
// The inspector loads its session entirely from the URL `#fragment`, which is
32+
// `uint32-LE(jsonByteLen) | rawDEFLATE(JSON)` then base64url (unpadded). The
33+
// bootstrap only needs truthy `t`, `k`, `p` plus an `exp` (unix seconds) in the
34+
// PAST to immediately render the "This webhook has expired" card - it returns
35+
// before any crypto/network, so placeholder `k`/`p` are fine. Note: opening such
36+
// a link writes the demo entry into localStorage (`webhook.sessions`); the
37+
// "Remove from list" button on the expired card evicts it again.
38+
//
39+
// Re-generate (the 4-byte length prefix is written but ignored on decode):
40+
// python3 - <<'PY'
41+
// import json, zlib, struct, base64, time
42+
// exp = int(time.time()) - 86400 # 24h in the past -> always expired
43+
// s = {"t":"ExpiredDemo01","k":{"kty":"EC","crv":"P-256","d":"demo","x":"demo",
44+
// "y":"demo","key_ops":["deriveBits"],"ext":True},
45+
// "p":"BDemoPublicKeyPlaceholderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
46+
// "l":"Expired webhook demo","d":"Demo of the expired-webhook take-over screen.",
47+
// "createdAt":(exp-7*86400)*1000,"exp":exp,
48+
// "m":{"s":200,"h":[["Content-Type","application/json; charset=utf-8"]],"b":"{\"ok\":true}"}}
49+
// raw = json.dumps(s, separators=(",",":")).encode()
50+
// co = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
51+
// out = struct.pack("<I", len(raw)) + co.compress(raw) + co.flush()
52+
// print("https://tools.secutils.dev/webhook#" + base64.urlsafe_b64encode(out).decode().rstrip("="))
53+
// PY
54+
//
55+
// Example link (static `exp` in the past, so it renders expired forever):
56+
// https://tools.secutils.dev/webhook#pgEAAJ1Qy07DMBD8FWvPCTgV5IU40MKJSw7c2grlsVWC09hyNiFRlH9nDUW916fZ3fHOzC5AkMLbZBqL1SuetQzAAwXpAopmN9pxXdqRYeZvHkOuKsYVUxlOVzhfocL5U5se0j13bDPitqEejh7gxGpkB1w9MMzfOsVsKNqmfMc5a_MSa93yn-nGx-LtNY_4xqLWWomLL2fcKQp9ElSjwD-a_0-jXKGvR7SiLy1id_cbHXPC6oWNB1GUPCRhnERSShfGuF4sAxmESeTB2V2NU2_ctOb0e9jpjrAj_2M2yMtyYzhqTo3u7r963T2Jss5tj_Q80MmP4cg3KtjkcgCtDpdTwbr-AA
2757
// =============================================================================
2858
(() => {
2959
// The webhook's default absolute lifetime. The key is written with this TTL, so its
@@ -486,6 +516,9 @@
486516
.expired-title { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
487517
.expired-sub { font-size: 13px; color: var(--text-muted); max-width: 540px; margin: 0 auto 8px; line-height: 1.5; }
488518
.expired-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin: 16px 0; }
519+
/* Secondary, utility action (local-only "Remove from list") sits below the lead-magnet
520+
copy, separated by a hairline so it reads as a lesser action than clone / sign-up. */
521+
.expired-actions-secondary { margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--border); }
489522

490523
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; flex-shrink: 0; }
491524
@keyframes spin { to { transform: rotate(360deg); } }
@@ -603,6 +636,9 @@ <h1 class="page-title">Webhook inspector</h1>
603636
<a class="btn" href="https://secutils.dev/signup" target="_blank" rel="noopener noreferrer">Create a free account &rarr;</a>
604637
</div>
605638
<p class="expired-sub">A free Secutils.dev account gives you <strong>permanent</strong> webhooks that never expire, plus scheduled checks, notifications, and more.</p>
639+
<div class="expired-actions expired-actions-secondary">
640+
<button id="expiredDeleteBtn" type="button" class="btn btn-danger">Remove from list</button>
641+
</div>
606642
</section>
607643

608644
<details class="card resp-card" id="respCard">
@@ -743,6 +779,7 @@ <h2 id="confirmDialogTitle">Confirm</h2>
743779
lifeChip: $('lifeChip'),
744780
expiredState: $('expiredState'),
745781
expiredCloneBtn: $('expiredCloneBtn'),
782+
expiredDeleteBtn: $('expiredDeleteBtn'),
746783
requestsCard: $('requestsCard'),
747784
ephemeralWhen: $('ephemeralWhen'),
748785
webhookUrl: $('webhookUrl'),
@@ -788,6 +825,66 @@ <h2 id="confirmDialogTitle">Confirm</h2>
788825
return { s, h, b };
789826
}
790827

828+
// Curated response-header suggestions surfaced via native <datalist> dropdowns
829+
// (mirrors echo.html). Names shown verbatim; values looked up case-insensitively
830+
// (key = lowercased name). Names without an entry in HEADER_PRESETS still
831+
// autocomplete the name, but offer no value suggestions - same for any custom
832+
// (non-listed) header typed by the user.
833+
const HEADER_NAMES = [
834+
'Access-Control-Allow-Credentials', 'Access-Control-Allow-Headers',
835+
'Access-Control-Allow-Methods', 'Access-Control-Allow-Origin',
836+
'Cache-Control', 'Content-Disposition', 'Content-Encoding', 'Content-Length',
837+
'Content-Security-Policy', 'Content-Type', 'Date', 'ETag', 'Last-Modified',
838+
'Location', 'Server', 'Set-Cookie', 'Strict-Transport-Security', 'Vary',
839+
'WWW-Authenticate', 'X-Content-Type-Options', 'X-Frame-Options',
840+
];
841+
const HEADER_PRESETS = Object.freeze({
842+
'content-type': ['application/json; charset=utf-8', 'text/html; charset=utf-8', 'text/plain', 'application/xml', 'text/css', 'application/javascript', 'image/png', 'image/jpeg', 'application/octet-stream'],
843+
'cache-control': ['no-cache', 'no-store', 'max-age=0', 'max-age=3600', 'public, max-age=86400', 'private', 'must-revalidate'],
844+
'content-encoding': ['gzip', 'br', 'deflate', 'identity'],
845+
'server': ['nginx', 'Apache', 'cloudflare', 'Microsoft-IIS/10.0'],
846+
'access-control-allow-origin': ['*', 'https://example.com', 'null'],
847+
'access-control-allow-methods': ['GET, POST, PUT, DELETE, OPTIONS', 'GET, HEAD, OPTIONS'],
848+
'access-control-allow-headers': ['Content-Type, Authorization', '*'],
849+
'access-control-allow-credentials': ['true', 'false'],
850+
'vary': ['Accept-Encoding', 'Origin', 'Accept, Accept-Encoding', 'User-Agent'],
851+
'content-disposition': ['inline', 'attachment', 'attachment; filename="file.pdf"'],
852+
'strict-transport-security': ['max-age=31536000', 'max-age=31536000; includeSubDomains', 'max-age=63072000; includeSubDomains; preload'],
853+
'x-frame-options': ['DENY', 'SAMEORIGIN'],
854+
'x-content-type-options': ['nosniff'],
855+
'www-authenticate': ['Bearer', 'Bearer realm="api"', 'Bearer error="invalid_token"', 'Basic realm="api"'],
856+
'content-security-policy': [`default-src 'self'`, `default-src 'self'; script-src 'self' 'unsafe-inline'`, `frame-ancestors 'none'`],
857+
});
858+
859+
// Shared <datalist> of response-header names - created once and reused across all
860+
// name inputs (every row links to the same `list="resp-hdr-names"` target).
861+
const respNamesDatalist = document.createElement('datalist');
862+
respNamesDatalist.id = 'resp-hdr-names';
863+
for (const name of HEADER_NAMES) {
864+
const opt = document.createElement('option');
865+
opt.value = name;
866+
respNamesDatalist.appendChild(opt);
867+
}
868+
document.body.appendChild(respNamesDatalist);
869+
870+
// Populate a per-row value <datalist> from HEADER_PRESETS keyed by the lowercased
871+
// header name, and toggle the value input's `list` attribute so rows without
872+
// presets don't render an empty dropdown indicator.
873+
const applyValuePresets = (valueInput, valueDatalist, headerName) => {
874+
const presets = HEADER_PRESETS[headerName.trim().toLowerCase()];
875+
valueDatalist.replaceChildren();
876+
if (presets) {
877+
for (const v of presets) {
878+
const opt = document.createElement('option');
879+
opt.value = v;
880+
valueDatalist.appendChild(opt);
881+
}
882+
valueInput.setAttribute('list', valueDatalist.id);
883+
} else {
884+
valueInput.removeAttribute('list');
885+
}
886+
};
887+
791888
const MOUNT = location.pathname.replace(/\/$/, '') || '/webhook';
792889
const CATALOG_KEY = 'webhook.sessions';
793890
const ACTIVE_KEY = 'webhook.active';
@@ -1285,14 +1382,22 @@ <h2 id="confirmDialogTitle">Confirm</h2>
12851382
nameInput.className = 'input input-mono';
12861383
nameInput.placeholder = 'Header name';
12871384
nameInput.value = row[0];
1385+
nameInput.setAttribute('list', 'resp-hdr-names');
12881386
const valueInput = document.createElement('input');
12891387
valueInput.className = 'input input-mono';
12901388
valueInput.placeholder = 'Header value';
12911389
valueInput.value = row[1];
1292-
nameInput.addEventListener('input', (e) => { mockState.h[i][0] = e.target.value; markMockDirty(); });
1390+
const valueDatalist = document.createElement('datalist');
1391+
valueDatalist.id = 'resp-hdr-vals-' + i;
1392+
applyValuePresets(valueInput, valueDatalist, row[0]);
1393+
nameInput.addEventListener('input', (e) => {
1394+
mockState.h[i][0] = e.target.value;
1395+
applyValuePresets(valueInput, valueDatalist, e.target.value);
1396+
markMockDirty();
1397+
});
12931398
valueInput.addEventListener('input', (e) => { mockState.h[i][1] = e.target.value; markMockDirty(); });
12941399
const tdK = document.createElement('td'); tdK.appendChild(nameInput);
1295-
const tdV = document.createElement('td'); tdV.appendChild(valueInput);
1400+
const tdV = document.createElement('td'); tdV.append(valueInput, valueDatalist);
12961401
const tdRm = document.createElement('td');
12971402
const rm = document.createElement('button');
12981403
rm.type = 'button'; rm.className = 'btn btn-icon'; rm.title = 'Remove header';
@@ -1490,6 +1595,20 @@ <h2 id="confirmDialogTitle">Confirm</h2>
14901595
els.clearBtn.disabled = false;
14911596
}
14921597
});
1598+
// Drop a session from the local catalog (no server call) and move to the next saved
1599+
// webhook, or mint a fresh one when the list is now empty. Shared by the full delete
1600+
// flow (after the server purge) and the expired take-over's local-only removal.
1601+
async function forgetSessionLocally(token) {
1602+
const catalog = loadCatalog().filter((s) => s.t !== token);
1603+
saveCatalog(catalog);
1604+
broadcast('catalog');
1605+
if (catalog.length) {
1606+
await activateSession(catalog[0], { register: false });
1607+
} else {
1608+
await createNewSession();
1609+
}
1610+
}
1611+
14931612
els.deleteBtn.addEventListener('click', async () => {
14941613
if (!current) return;
14951614
const ok = await confirmAction({
@@ -1513,18 +1632,31 @@ <h2 id="confirmDialogTitle">Confirm</h2>
15131632
toast(`Server delete failed: ${e.message}`);
15141633
}
15151634
// Remove from local catalog regardless of server outcome.
1516-
const catalog = loadCatalog().filter((s) => s.t !== token);
1517-
saveCatalog(catalog);
1518-
broadcast('catalog');
1635+
await forgetSessionLocally(token);
15191636
els.deleteBtn.disabled = false;
1520-
if (catalog.length) {
1521-
await activateSession(catalog[0], { register: false });
1522-
} else {
1523-
await createNewSession();
1524-
}
15251637
toast('Webhook deleted');
15261638
});
15271639

1640+
// Expired webhooks no longer exist server-side (key, config, and requests were swept at
1641+
// the deadline), so there is nothing to DELETE - just evict the stale entry from the
1642+
// local catalog so it stops cluttering the dropdown.
1643+
els.expiredDeleteBtn.addEventListener('click', async () => {
1644+
if (!current) return;
1645+
const ok = await confirmAction({
1646+
title: 'Remove webhook',
1647+
message: 'Remove this expired webhook from your list? It has already been deleted from the server and cannot be recovered.',
1648+
confirmLabel: 'Remove',
1649+
});
1650+
if (!ok) return;
1651+
els.expiredDeleteBtn.disabled = true;
1652+
try {
1653+
await forgetSessionLocally(current.t);
1654+
toast('Webhook removed');
1655+
} finally {
1656+
els.expiredDeleteBtn.disabled = false;
1657+
}
1658+
});
1659+
15281660
if (channel) {
15291661
channel.onmessage = (e) => {
15301662
if (e.data && e.data.type === 'catalog') renderSessionSelect();

0 commit comments

Comments
 (0)