Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/admin/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
`;

Expand Down Expand Up @@ -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"
/>
</td>
<td class="muted">${localTime(r.lastUsed)}</td>
Expand Down Expand Up @@ -192,8 +192,9 @@ export const dashboardPage = () => html`<!doctype 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 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()"
>
<div class="wrap">
Expand Down Expand Up @@ -238,7 +239,7 @@ export const dashboardPage = () => html`<!doctype html>
</div>
<div class="card">
<h2>Tokens</h2>
<div id="tokens" hx-sync="this:drop" hx-get="/admin/api/tokens" hx-trigger="load, every 120s [document.visibilityState==='visible' && document.activeElement?.type !== 'datetime-local']">
<div id="tokens" hx-sync="this:drop" hx-get="/admin/api/tokens" hx-trigger="load, every 120s [document.visibilityState==='visible' && !document.querySelector('.editing')]">
Loading…
</div>
</div>
Expand Down
11 changes: 8 additions & 3 deletions test/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');

Expand Down Expand Up @@ -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(
Expand All @@ -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(
"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).
expect(page).toContain('id="tokens" hx-sync="this:drop"');
Expand Down
Loading