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
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 →</ 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+
791888const MOUNT = location . pathname . replace ( / \/ $ / , '' ) || '/webhook' ;
792889const CATALOG_KEY = 'webhook.sessions' ;
793890const 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+
14931612els . 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+
15281660if ( channel ) {
15291661 channel . onmessage = ( e ) => {
15301662 if ( e . data && e . data . type === 'catalog' ) renderSessionSelect ( ) ;
0 commit comments