From a26cf0dae2ac77836c06252b0986dfe2db5501af Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 23:05:55 +0530 Subject: [PATCH 1/3] fix: restore Today-to-EOD at input time, commit expiry edits on editor close The dedup in #11 moved the create form's Today snap from input time to submit time and then deleted it, so a picker Today click saved the current minute and expired the token instantly. The snap is back at its original site - the moment the picker fills the current minute - as one body-level delegate serving the form and every row editor, rewriting the field visibly; hand-typed other times never match it. Rows keep focus (any picker recommit of the current minute just re-enters the handler), while the form keeps the blur/showPicker reread dance. Trusted-keystroke testing also showed Chromium fires change per segment edit on a valid datetime-local, so the change trigger committed half-edited values and swapped the row mid-edit. Row edits now commit when the editor closes with a value different from what it opened with (focusout fires a custom commit trigger); unchanged closes are silent. --- docs/architecture.md | 2 +- src/admin/views.ts | 7 ++++--- test/admin.test.ts | 9 +++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0e99d49..36470a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,7 +184,7 @@ Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via ` - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. - **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Creation, patches, and deletes are serialized through a per-hash `TokenWriter` Durable Object whose own storage is the merge base (KV guarantees neither atomic read-modify-write nor read-your-write, so merging from a KV read let a stale expiry patch resurrect a disabled token); KV is written through for the hot path, only pre-writer hashes bootstrap from a KV read, a deletion tombstone blocks stale echoes, and recreation clears it. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. -- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO exactly as picked - a past pick renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. Polls and mutations share the persistent `#tokens` sync scope; in-flight rows dim but stay clickable so same-row gestures reach the writer. A different row's mutation can abort the earlier row swap, leaving that cell stale until the next poll. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. +- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO when the editor closes with a changed value (per-segment change events must not commit half-edited values); a picker "Today" fill snaps to 23:59 visibly in the field before submit while hand-typed times are untouched; a past value renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. Polls and mutations share the persistent `#tokens` sync scope; in-flight rows dim but stay clickable so same-row gestures reach the writer. A different row's mutation can abort the earlier row swap, leaving that cell stale until the next poll. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. ## 12. Real key handling diff --git a/src/admin/views.ts b/src/admin/views.ts index 91c9dd3..5520716 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -93,7 +93,7 @@ export const tokenRow = (r: TokenRow) => { aria-label="expiry" style="display:none" hx-put="/admin/api/tokens/${r.hash}" - hx-trigger="change" + hx-trigger="commit" /> ${localTime(r.lastUsed)} @@ -192,8 +192,9 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" - hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; try { i.showPicker() } catch {} i.focus() }" + hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; if (!t.closest('#rows')) { t.blur(); try { t.showPicker() } catch {} } }" + hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { if (t.value !== t.dataset.prefill) htmx.trigger(t, 'commit'); t.style.display = 'none'; t.previousElementSibling.style.display = '' }" hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" >
diff --git a/test/admin.test.ts b/test/admin.test.ts index f862763..2949d1d 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -176,7 +176,7 @@ describe("admin token CRUD", () => { expect(table).toContain('datetime="2030-01-01T00:00:00.000Z"'); expect(table).toContain("2030-01-01 00:00 UTC"); // Each row carries a click-to-edit expiry editor (behavior is delegated at the body). - expect(table).toContain('hx-trigger="change"'); + expect(table).toContain('hx-trigger="commit"'); expect(table).toContain('data-iso="2030-01-01T00:00:00.000Z"'); expect(table).toContain('hx-indicator="closest tr"'); @@ -390,8 +390,13 @@ describe("admin token CRUD", () => { expect(page).toContain("code.copy"); expect(page).toContain("navigator.clipboard.writeText"); expect(page).toContain('name="label" placeholder="alice-laptop" required'); - // One body-level converter serves every datetime-local; values save exactly as picked. + // One body-level converter serves every datetime-local. expect(page).toContain("p.expiresAt = new Date(p.expiresAt).toISOString()"); + // Input-time snap: a picker "Today" fill becomes 23:59 in the field; typed times don't match. + expect(page).toContain("t.value.slice(0, 11) + '23:59'"); + expect(page).toContain( + "if (!t.closest('#rows')) { t.blur(); try { t.showPicker() } catch {} }", + ); // The poll and row mutations share one persistent sync scope (in-flight polls // must never overwrite a newer row swap). expect(page).toContain('id="tokens" hx-sync="this:drop"'); From 230400c6a43f76bc17d322fcb4194133088b6727 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sun, 12 Jul 2026 00:06:47 +0530 Subject: [PATCH 2/3] fix: anchor the picker to the laid-out input, commit rows instantly on a Today fill showPicker() ran in the same tick the input flipped from display:none, so Chromium anchored the popup to a zero rect at the screen corner - focus and a forced layout read now precede it. And instead of leaving the open popup displaying a stale time after the snap, a row's Today fill commits immediately: the row re-renders to 11:59 PM and the popup closes with the swapped editor, so there is no mismatch window at all. The create form keeps its blur/showPicker reread dance. --- src/admin/views.ts | 4 ++-- test/admin.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/admin/views.ts b/src/admin/views.ts index 5520716..3552ee6 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -192,8 +192,8 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; try { i.showPicker() } catch {} i.focus() }" - hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; if (!t.closest('#rows')) { t.blur(); try { t.showPicker() } catch {} } }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; i.focus(); i.offsetWidth; try { i.showPicker() } catch {} }" + hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; if (t.closest('#rows')) htmx.trigger(t, 'commit'); else { t.blur(); try { t.showPicker() } catch {} } }" hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { if (t.value !== t.dataset.prefill) htmx.trigger(t, 'commit'); t.style.display = 'none'; t.previousElementSibling.style.display = '' }" hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" > diff --git a/test/admin.test.ts b/test/admin.test.ts index 2949d1d..b6450b1 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -395,7 +395,7 @@ describe("admin token CRUD", () => { // Input-time snap: a picker "Today" fill becomes 23:59 in the field; typed times don't match. expect(page).toContain("t.value.slice(0, 11) + '23:59'"); expect(page).toContain( - "if (!t.closest('#rows')) { t.blur(); try { t.showPicker() } catch {} }", + "if (t.closest('#rows')) htmx.trigger(t, 'commit'); else { t.blur(); try { t.showPicker() } catch {} }", ); // The poll and row mutations share one persistent sync scope (in-flight polls // must never overwrite a newer row swap). From 4ce9a6b460095162bb08ddd8e42b087484469251 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sun, 12 Jul 2026 00:36:20 +0530 Subject: [PATCH 3/3] fix: keep the picker alive through a Today snap, stop editor swaps eating clicks A Today fill no longer commits and closes the editor out from under the user: rows now run the same snap dance the create form always has (rewrite the field, blur, showPicker) so the reopened picker shows 23:59 and stays interactive; a flag keeps the dance's blur from closing the editor. Commits happen when the user actually leaves: native focusout, or a body-level click-outside route for the post-dance state where the input is no longer focused. The poll pauses on an .editing marker instead of activeElement for the same reason. The display button and the editor also share one fixed footprint: swapping them used to reflow the row between mousedown and mouseup, so the click aimed at another row's edit button (or anything nearby) landed on nothing - which is why the editor 'would not open again'. --- src/admin/views.ts | 12 ++++++------ test/admin.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/admin/views.ts b/src/admin/views.ts index 3552ee6..c7c2a53 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -38,10 +38,10 @@ tr.empty:not(:only-child){display:none} .notice code{display:block;background:#04140c;padding:8px 10px;border-radius:6px;margin-top:6px;word-break:break-all} .copy{cursor:pointer} .copy.copied::after{content:"✓";float:right;color:#74e0bb} -.edit{background:none;border:0;padding:0;font:inherit;cursor:pointer} +.edit{background:none;border:0;padding:0;font:inherit;cursor:pointer;width:170px;text-align:left} tr.htmx-request button{opacity:.5} .edit:hover{text-decoration:underline dotted} -td input[type=datetime-local]{width:auto;padding:4px 6px;font-size:12px} +td input[type=datetime-local]{width:170px;padding:4px 6px;font-size:12px} .danger{color:#f08a8a;border-color:#5c2a2a} `; @@ -192,9 +192,9 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; i.focus(); i.offsetWidth; try { i.showPicker() } catch {} }" - hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; if (t.closest('#rows')) htmx.trigger(t, 'commit'); else { t.blur(); try { t.showPicker() } catch {} } }" - hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { if (t.value !== t.dataset.prefill) htmx.trigger(t, 'commit'); t.style.display = 'none'; t.previousElementSibling.style.display = '' }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const o = document.querySelector('#rows .editing'); if (o && !o.parentElement.contains(event.target)) o.dispatchEvent(new Event('focusout', { bubbles: true })); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; i.classList.add('editing'); const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; i.dataset.prefill = i.value; i.focus(); i.offsetWidth; try { i.showPicker() } catch {} }" + hx-on:input="const t = event.target; if (t.type === 'datetime-local' && t.value.slice(11) === new Date().toTimeString().slice(0, 5)) { t.value = t.value.slice(0, 11) + '23:59'; t.dataset.snap = '1'; t.blur(); try { t.showPicker() } catch {} }" + hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { if (t.dataset.snap) { delete t.dataset.snap; return } if (t.value !== t.dataset.prefill) htmx.trigger(t, 'commit'); t.classList.remove('editing'); t.style.display = 'none'; t.previousElementSibling.style.display = '' }" hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" >
@@ -239,7 +239,7 @@ export const dashboardPage = () => html`

Tokens

-
+
Loading…
diff --git a/test/admin.test.ts b/test/admin.test.ts index b6450b1..e8a99bc 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -368,7 +368,7 @@ describe("admin token CRUD", () => { const cookie = await login(); const page = await (await call("/admin", { headers: { cookie } })).text(); expect(page).toContain( - "every 120s [document.visibilityState==='visible' && document.activeElement?.type !== 'datetime-local']", + "every 120s [document.visibilityState==='visible' && !document.querySelector('.editing')]", ); // Pin the hash so HTMX upgrades must update SRI deliberately. expect(page).toContain( @@ -395,7 +395,7 @@ describe("admin token CRUD", () => { // Input-time snap: a picker "Today" fill becomes 23:59 in the field; typed times don't match. expect(page).toContain("t.value.slice(0, 11) + '23:59'"); expect(page).toContain( - "if (t.closest('#rows')) htmx.trigger(t, 'commit'); else { t.blur(); try { t.showPicker() } catch {} }", + "t.dataset.snap = '1'; t.blur(); try { t.showPicker() } catch {}", ); // The poll and row mutations share one persistent sync scope (in-flight polls // must never overwrite a newer row swap).