From a83cb8f56c7b64e9e86ffee433de53a3a494a8a5 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:00:18 -0700 Subject: [PATCH 1/9] Add dependency-free admin SPA markup --- src/prod/Indirection/wwwroot/admin/index.html | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/prod/Indirection/wwwroot/admin/index.html diff --git a/src/prod/Indirection/wwwroot/admin/index.html b/src/prod/Indirection/wwwroot/admin/index.html new file mode 100644 index 0000000..084a5f1 --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/index.html @@ -0,0 +1,135 @@ + + + + + + + Indirection · Links + + + + + + + +
+
YOUR WORKSPACE LINK MANAGEMENT
+
+
+

Links

+

Manage your short URLs and where they lead.

+
+ +
+ +
+
+
+

All links

+ 0 +
+
+ + +
+
+
+ + + +
SHORT LINKDESTINATIONCREATEDActions
+
+ + +
+
Codes are case-insensitive and can include nested paths, like docs/start.
+
+ +
+ +
+ + +
+
+
+ CREATE REDIRECT +

New link

+
+ +
+
+
+

Give this destination a short, memorable address.

+ +
/
+
Letters, numbers, and slashes are supported.
+ + +
The address people will be redirected to.
+
+ SHORT LINK PREVIEW + — +
+ +
+
+ + + + +
+
+
+
+ + + \ No newline at end of file From ba9117416b0f7e3c67096613e2b531f8be0c5e6e Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:01:24 -0700 Subject: [PATCH 2/9] Style responsive admin UI without frontend dependencies --- src/prod/Indirection/wwwroot/admin/styles.css | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/prod/Indirection/wwwroot/admin/styles.css diff --git a/src/prod/Indirection/wwwroot/admin/styles.css b/src/prod/Indirection/wwwroot/admin/styles.css new file mode 100644 index 0000000..cb00cec --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/styles.css @@ -0,0 +1,9 @@ +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1c2933;background:#f8f9fa;-webkit-font-smoothing:antialiased}*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea{font:inherit}button{cursor:pointer}button:disabled{opacity:.55;cursor:wait}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #c6d8ed;outline-offset:2px}svg.icon{width:17px;height:17px;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.site-header{height:76px;border-bottom:1px solid #e9ecef;background:#fff}.header-inner{max-width:1180px;height:100%;margin:auto;padding:0 28px;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:11px;font-size:18px;font-weight:780;letter-spacing:-.8px}.brand-dot{color:#e0a15d}.brand-symbol{width:33px;height:33px;border-radius:9px;background:#1e2a34;color:#fff;display:grid;place-items:center}.brand-symbol svg{width:19px;height:19px}.header-right{display:flex;align-items:center;gap:21px}.hostname{font-size:12px;color:#8b949e;font-weight:560}.key-button{border:1px solid #e8ebee;border-radius:7px;background:#fff;height:34px;padding:0 12px;display:flex;align-items:center;gap:7px;font-size:12px;font-weight:650;color:#54616b}.key-button .icon{width:14px;height:14px}.key-button:hover{background:#f8f9fa} +.page{max-width:1180px;margin:auto;padding:56px 28px 64px}.eyebrow{display:flex;align-items:center;gap:11px;color:#8b969e;font-size:10px;letter-spacing:1.5px;font-weight:760}.eyebrow-line{height:1px;width:26px;background:#ced5da}.page-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin:13px 0 33px}.page-heading h1{font-size:38px;letter-spacing:-1.65px;line-height:1.15;margin:0;font-weight:750}.page-heading p{font-size:14px;color:#85909a;margin:10px 0 0}.button{border-radius:7px;border:1px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap;font-size:13px;font-weight:680;min-height:40px;padding:0 16px;transition:background .15s,border-color .15s}.button .icon{width:16px;height:16px}.button-primary{background:#253643;color:#fff;box-shadow:0 1px 2px #1a2c3618}.button-primary:hover{background:#344c5c}.button-secondary{background:#fff;color:#46535f;border-color:#e0e5e9}.button-secondary:hover{background:#f6f8f9}.button-danger{color:#b54d51;background:#fff;border-color:#efd4d5}.button-danger:hover{background:#fff3f3} +.list-panel{border:1px solid #e7ebee;border-radius:11px;background:#fff;box-shadow:0 6px 20px rgba(27,38,45,.025);overflow:hidden}.list-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px}.list-heading{display:flex;align-items:center;gap:11px}.list-heading h2{font-size:15px;letter-spacing:-.25px;margin:0;font-weight:730}.count-pill{font-size:11px;min-width:23px;height:23px;padding:3px 7px;text-align:center;border-radius:7px;background:#f0f2f4;color:#67737c;font-weight:750}.toolbar-actions{display:flex;align-items:center;gap:9px}.search-wrap{width:245px;height:36px;border:1px solid #e7ebee;border-radius:7px;display:flex;align-items:center;gap:9px;padding:0 11px;color:#a4adb5;background:#fff}.search-wrap:focus-within{border-color:#b5c3cc}.search-wrap .icon{width:15px;height:15px}.search-wrap input{border:0;outline:0;min-width:0;flex:1;font-size:12px;color:#2b3640;background:transparent}.search-wrap input::placeholder{color:#a3adb4}.shortcut{font-size:10px;color:#a0a9b1;white-space:nowrap;border:1px solid #edf0f2;border-radius:4px;padding:1px 4px}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:33px;height:33px;border:1px solid transparent;border-radius:7px;background:transparent;color:#84919b}.icon-button:hover{background:#f1f4f6;color:#283945}.refresh{border-color:#e7ebee;width:36px;height:36px}.refresh .icon{width:16px;height:16px} +.table-scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;text-align:left;table-layout:fixed}thead{background:#fafbfc}thead th{height:41px;text-transform:uppercase;font-size:10px;letter-spacing:1.1px;color:#a0a9b2;font-weight:750;border-top:1px solid #eef1f3;border-bottom:1px solid #eef1f3;padding:0 24px}th:nth-child(1){width:29%}th:nth-child(2){width:45%}th:nth-child(3){width:16%}th:nth-child(4){width:10%}tbody tr{border-bottom:1px solid #f0f2f4}tbody tr:hover{background:#fcfdfd}tbody tr:last-child{border-bottom:0}tbody td{padding:17px 24px;font-size:12px;color:#8b959e;vertical-align:middle}tbody td:nth-child(1),tbody td:nth-child(2){overflow:hidden}.code-cell{display:flex;align-items:center;min-width:0;gap:11px}.link-avatar{flex-shrink:0;width:32px;height:32px;background:#f1f5f6;border:1px solid #e9eef0;display:grid;place-items:center;border-radius:8px;color:#5f8290}.link-avatar .icon{width:15px;height:15px}.code-name{min-width:0;font-weight:690;color:#273945;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.code-name:hover{text-decoration:underline;cursor:pointer}.domain-text{display:block;color:#6e7a84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.6}.path-text{display:block;color:#a1abb3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}.row-actions{display:flex;align-items:center;justify-content:end;gap:2px}.row-actions button{width:29px;height:29px}.row-actions .icon{width:15px;height:15px}.table-footer{height:54px;border-top:1px solid #edf0f2;display:flex;justify-content:space-between;align-items:center;padding:0 24px;color:#a1abb3;font-size:11px}.table-footer span:first-child{color:#6a7781;font-weight:630}.page-footnote{margin-top:22px;color:#a6afb6;font-size:11px;text-align:center}.page-footnote code{font-family:ui-monospace,Consolas,monospace;background:#eff1f3;padding:3px 5px;border-radius:4px;color:#687782}.empty-state{text-align:center;padding:65px 20px 75px}.empty-art{height:55px;width:55px;display:grid;place-items:center;background:#f5f7f8;border-radius:15px;margin:0 auto 17px;color:#92a0ab}.empty-art svg{width:30px;height:30px;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.empty-state h3{font-size:16px;margin:0 0 7px}.empty-state p{font-size:13px;color:#99a3ac;margin:0 0 20px} +.login{max-width:448px;margin:0 auto;padding:105px 25px 80px}.login-card{border-radius:14px;border:1px solid #e9ecef;background:#fff;box-shadow:0 15px 40px #202e3710;padding:35px}.login-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:11px;color:#fff;background:#22333f;margin-bottom:29px}.login-symbol svg{width:24px;height:24px;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.login-kicker,.drawer-eyebrow{font-size:10px;letter-spacing:1.4px;font-weight:800;color:#a1abb3}.login h1{font-size:25px;letter-spacing:-1px;margin:12px 0 9px}.login p{color:#88939b;font-size:13px;line-height:1.65;margin:0 0 30px}.login label,.editor-fields label{display:block;font-size:12px;font-weight:700;color:#42505b;margin-bottom:10px}.password-wrap{position:relative}.password-wrap input,.editor-fields input,.editor-fields textarea{width:100%;background:#fff;border:1px solid #dfe5e9;border-radius:7px;padding:12px 13px;font-size:13px;color:#253744;outline:0}.password-wrap input{padding-right:62px}.password-wrap input:focus,.editor-fields input:focus,.editor-fields textarea:focus{border-color:#8eabbe;box-shadow:0 0 0 3px #d9e7ef80}.show-password{position:absolute;right:8px;top:8px;border:0;background:#f4f6f7;font-size:11px;color:#60717d;border-radius:5px;padding:6px 8px}.full-width{width:100%;margin-top:19px;justify-content:space-between;padding:0 16px}.login-note{display:flex;gap:9px;color:#9fa8b1;line-height:1.6;font-size:11px;border-top:1px solid #eef0f2;margin-top:25px;padding-top:21px}.login-note .icon{width:15px;height:15px;flex-shrink:0;margin-top:1px} +.drawer{border:0;padding:0;margin:0 0 0 auto;width:min(100%,460px);height:100dvh;max-height:100dvh;background:white;box-shadow:-15px 0 40px #0c182b14;color:#253744}.drawer::backdrop{background:rgba(19,34,45,.34)}.drawer[open]{animation:slide-in .2s ease-out}@keyframes slide-in{from{transform:translateX(20px);opacity:.8}to{transform:translateX(0);opacity:1}}.drawer-inner{height:100%;display:flex;flex-direction:column}.drawer-header{padding:33px 34px 27px;display:flex;align-items:start;justify-content:space-between;border-bottom:1px solid #edf0f2}.drawer-header h2{font-size:25px;letter-spacing:-.85px;margin:8px 0 0}.drawer-header .icon-button{margin-top:-3px}.editor-form{min-height:0;flex:1;display:flex;flex-direction:column;justify-content:space-between}.editor-fields{padding:28px 34px;overflow:auto}.editor-description{font-size:13px;line-height:1.55;color:#89949e;margin:0 0 31px}.code-field{display:flex;align-items:center;border:1px solid #dfe5e9;border-radius:7px;overflow:hidden}.code-field:focus-within{border-color:#8eabbe;box-shadow:0 0 0 3px #d9e7ef80}.code-field span{display:block;padding:11px 0 11px 13px;color:#84929d;font-size:13px}.code-field input{border:0;box-shadow:none!important;padding:12px 12px 12px 2px}.code-field input:disabled{color:#778692;background:#f9fafb}.field-help{font-size:11px;color:#a1abb3;line-height:1.5;margin-top:8px}.second-label{margin-top:28px}.editor-fields textarea{resize:vertical;line-height:1.5;min-height:92px}.preview-box{background:#f6f8f9;border:1px solid #edf1f3;border-radius:9px;margin-top:29px;padding:18px}.preview-box span{display:block;font-size:10px;letter-spacing:1px;font-weight:800;color:#9ba6af;margin-bottom:10px}.preview-box strong{font-family:ui-monospace,Consolas,monospace;color:#385c6d;font-size:12px;word-break:break-all;font-weight:650}.drawer-footer{border-top:1px solid #edf0f2;padding:20px 26px;display:flex;gap:8px}.footer-spacer{flex:1}.form-error{font-size:12px!important;color:#b44c4f!important;line-height:1.5!important;margin:13px 0 0!important}.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);z-index:10;background:#263b47;color:#fff;border-radius:8px;box-shadow:0 10px 30px #13212c2e;padding:12px 17px;font-size:12px;font-weight:660;white-space:nowrap} +@media(max-width:760px){.site-header{height:65px}.header-inner{padding:0 18px}.hostname{display:none}.page{padding:35px 16px}.page-heading{margin-bottom:25px}.page-heading h1{font-size:32px}.page-heading p{font-size:13px;max-width:260px}.page-heading .button{padding:0 12px}.list-toolbar{padding:16px;align-items:stretch;flex-direction:column}.toolbar-actions{width:100%}.search-wrap{flex:1;width:auto}.shortcut{display:none}.table-scroll{overflow:visible}table,tbody{display:block;width:100%}thead{display:none}tbody tr{display:grid;grid-template-columns:1fr auto;gap:3px 10px;padding:15px 17px}tbody td{padding:0;min-width:0}tbody td:first-child{grid-column:1;grid-row:1}tbody td:nth-child(2){grid-column:1;grid-row:2;margin-left:43px}tbody td:nth-child(3){grid-column:1;grid-row:3;margin-left:43px;font-size:11px}tbody td:nth-child(4){grid-column:2;grid-row:1/span 3;align-self:center}.row-actions{gap:0}.table-footer{padding:0 17px}.table-footer span:nth-child(2){display:none}.page-footnote{line-height:1.7}.drawer-header{padding:26px 23px 23px}.editor-fields{padding:26px 23px}.drawer-footer{padding:17px 14px}.drawer-footer .button{padding:0 11px}.login{padding:54px 17px}.login-card{padding:25px}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}} From b771d255d455e48318ad62a2b27891733a1b9bd9 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:02:14 -0700 Subject: [PATCH 3/9] Implement single-page link management against existing API --- src/prod/Indirection/wwwroot/admin/app.js | 312 ++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/prod/Indirection/wwwroot/admin/app.js diff --git a/src/prod/Indirection/wwwroot/admin/app.js b/src/prod/Indirection/wwwroot/admin/app.js new file mode 100644 index 0000000..f658bf6 --- /dev/null +++ b/src/prod/Indirection/wwwroot/admin/app.js @@ -0,0 +1,312 @@ +/* Indirection admin: plain JavaScript, no build step or dependencies. */ +'use strict'; + +const $ = id => document.getElementById(id); +const state = { apiKey: null, links: [], editing: null, toastTimer: null }; +const endpoint = '/api/v1/links'; +const origin = window.location.origin; + +function apiPath(code) { + // Keep nested paths intact while encoding individual segments. + return endpoint + '/' + code.split('/').map(encodeURIComponent).join('/'); +} + +async function request(path, options = {}) { + const headers = { 'X-Api-Key': state.apiKey, ...options.headers }; + const response = await fetch(path, { ...options, headers, cache: 'no-store' }); + if (!response.ok) { + const error = new Error(response.status === 401 + ? 'Invalid API key.' + : 'Request failed (' + response.status + ').'); + error.status = response.status; + throw error; + } + if (response.status === 204) return null; + return response.headers.get('content-type')?.includes('json') + ? response.json() : null; +} + +function textNode(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + element.textContent = text; + return element; +} + +function icon(name) { + const paths = { + link: '', + copy: '', + edit: '' + }; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.8'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.classList.add('icon'); + svg.innerHTML = paths[name]; // Fixed, trusted icon markup only. + return svg; +} + +function publicUrl(code) { + return origin + '/' + code.split('/').map(encodeURIComponent).join('/'); +} + +function shortDestination(destination) { + try { + const url = new URL(destination); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return { top: url.hostname, bottom: url.pathname === '/' + ? (url.search || '—') : url.pathname + url.search }; + } + } catch { /* Custom or relative destinations are supported by the API. */ } + return { top: destination, bottom: 'Custom destination' }; +} + +function showToast(message) { + clearTimeout(state.toastTimer); + $('toast').textContent = message; + $('toast').hidden = false; + state.toastTimer = setTimeout(() => { $('toast').hidden = true; }, 2900); +} + +function showError(id, message) { + $(id).textContent = message; + $(id).hidden = !message; +} + +function showApp() { + $('login').hidden = true; + $('app').hidden = false; + $('headerRight').hidden = false; + $('hostname').textContent = location.hostname; +} + +function showLogin() { + state.apiKey = null; + state.links = []; + state.editing = null; + $('app').hidden = true; + $('headerRight').hidden = true; + $('login').hidden = false; + $('apiKey').value = ''; + if ($('editor').open) $('editor').close(); + $('apiKey').focus(); +} + +async function loadLinks() { + const response = await request(endpoint); + if (!Array.isArray(response)) throw new Error('The server returned an unexpected links response.'); + state.links = response.sort((a, b) => a.code.localeCompare(b.code)); + render(); +} + +function render() { + const query = $('search').value.trim().toLowerCase(); + const filtered = state.links.filter(link => link.code.toLowerCase().includes(query) + || link.destination.toLowerCase().includes(query)); + $('count').textContent = state.links.length; + $('resultCount').textContent = filtered.length === state.links.length + ? state.links.length + (state.links.length === 1 ? ' link' : ' links') + : filtered.length + ' of ' + state.links.length + ' links'; + $('linkRows').replaceChildren(); + + for (const link of filtered) { + const tr = document.createElement('tr'); + const codeCell = document.createElement('td'); + const codeWrap = textNode('div', 'code-cell', ''); + const avatar = textNode('span', 'link-avatar', ''); + avatar.append(icon('link')); + codeWrap.append(avatar); + const codeButton = textNode('button', 'code-name', link.code); + codeButton.type = 'button'; + codeButton.style.cssText = 'border:0;background:none;padding:0;text-align:left'; + codeButton.title = 'Edit ' + link.code; + codeButton.addEventListener('click', () => openEditor(link)); + codeWrap.append(codeButton); + codeCell.append(codeWrap); + + const destinationCell = document.createElement('td'); + const parts = shortDestination(link.destination); + const top = textNode('span', 'domain-text', parts.top); + top.title = link.destination; + const bottom = textNode('span', 'path-text', parts.bottom); + bottom.title = link.destination; + destinationCell.append(top, bottom); + const created = textNode('td', '', link.createdUtc + ? new Date(link.createdUtc).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + : '—'); + + const actionsCell = document.createElement('td'); + const actions = textNode('div', 'row-actions', ''); + const copy = textNode('button', 'icon-button', ''); + copy.type = 'button'; + copy.title = 'Copy short link'; + copy.setAttribute('aria-label', 'Copy ' + link.code); + copy.append(icon('copy')); + copy.addEventListener('click', () => copyUrl(link.code)); + const edit = textNode('button', 'icon-button', ''); + edit.type = 'button'; + edit.title = 'Edit link'; + edit.setAttribute('aria-label', 'Edit ' + link.code); + edit.append(icon('edit')); + edit.addEventListener('click', () => openEditor(link)); + actions.append(copy, edit); + actionsCell.append(actions); + tr.append(codeCell, destinationCell, created, actionsCell); + $('linkRows').append(tr); + } + + $('emptyState').hidden = filtered.length > 0; + $('linkTable').hidden = filtered.length === 0; + $('emptyTitle').textContent = state.links.length ? 'No matching links' : 'No links yet'; + $('emptySubtitle').textContent = state.links.length + ? 'Try a different search term.' : 'Create your first link to get started.'; + $('emptyCreateBtn').hidden = state.links.length > 0; +} + +async function copyUrl(code) { + try { + await navigator.clipboard.writeText(publicUrl(code)); + showToast('Short link copied to clipboard'); + } catch { showToast('Could not copy. Your browser may require HTTPS.'); } +} + +function openEditor(link = null) { + state.editing = link; + $('editorForm').reset(); + showError('editorError', ''); + $('editorEyebrow').textContent = link ? 'EDIT REDIRECT' : 'CREATE REDIRECT'; + $('editorTitle').textContent = link ? 'Edit link' : 'New link'; + $('saveBtn').textContent = link ? 'Save changes' : 'Create link'; + $('deleteBtn').hidden = !link; + $('code').disabled = Boolean(link); + $('code').value = link?.code ?? ''; + $('destination').value = link?.destination ?? ''; + $('codeHelp').textContent = link + ? 'A link code cannot be changed after creation.' + : 'Codes are case-insensitive and may contain nested paths.'; + updatePreview(); + $('editor').showModal(); + (link ? $('destination') : $('code')).focus(); +} + +function updatePreview() { + const code = getCode(); + $('previewUrl').textContent = code ? publicUrl(code) : origin + '/your-code'; +} + +function getCode() { + return $('code').value.trim().replace(/^\/+|\/+$/g, ''); +} + +function validateCode(code) { + if (!code || code.split('/').some(segment => !segment || segment === '.' || segment === '..')) { + return 'Enter a valid code without empty or dot-only path segments.'; + } + if (/[?#\\]/.test(code)) return 'Codes cannot contain ?, #, or backslashes.'; + const first = code.split('/')[0].toLowerCase(); + if (first === 'api' || first === 'admin' || code.toLowerCase() === 'health') { + return 'This path is reserved for the application.'; + } + if (!state.editing && state.links.some(link => link.code.toLowerCase() === code.toLowerCase())) { + return 'A link with this code already exists. Edit it instead.'; + } + return ''; +} + +async function saveLink(event) { + event.preventDefault(); + const code = getCode(); + const destination = $('destination').value.trim(); + const error = validateCode(code); + if (error) { showError('editorError', error); return; } + if (!destination) { showError('editorError', 'Enter a destination.'); return; } + const button = $('saveBtn'); + button.disabled = true; + showError('editorError', ''); + try { + await request(apiPath(code), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination }) + }); + await loadLinks(); + $('editor').close(); + showToast(state.editing ? 'Link updated' : 'Link created'); + } catch (e) { + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else showError('editorError', e.message); + } finally { button.disabled = false; } +} + +async function deleteLink() { + const link = state.editing; + if (!link || !confirm('Delete /' + link.code + '?\n\nThis will immediately stop the short link from redirecting.')) return; + const button = $('deleteBtn'); + button.disabled = true; + try { + await request(apiPath(link.code), { method: 'DELETE' }); + await loadLinks(); + $('editor').close(); + showToast('Link deleted'); + } catch (e) { + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else showError('editorError', e.message); + } finally { button.disabled = false; } +} + +$('loginForm').addEventListener('submit', async event => { + event.preventDefault(); + state.apiKey = $('apiKey').value; + $('connectBtn').disabled = true; + $('connectBtn').textContent = 'Connecting…'; + showError('loginError', ''); + try { await loadLinks(); showApp(); } + catch (e) { + state.apiKey = null; + showError('loginError', e.message || 'Unable to connect.'); + } finally { + $('connectBtn').disabled = false; + $('connectBtn').textContent = 'Connect →'; + } +}); +$('showKey').addEventListener('click', () => { + const showing = $('apiKey').type === 'text'; + $('apiKey').type = showing ? 'password' : 'text'; + $('showKey').textContent = showing ? 'Show' : 'Hide'; + $('showKey').setAttribute('aria-label', showing ? 'Show API key' : 'Hide API key'); +}); +$('disconnectBtn').addEventListener('click', showLogin); +$('search').addEventListener('input', render); +$('refreshBtn').addEventListener('click', async () => { + $('refreshBtn').disabled = true; + try { await loadLinks(); showToast('Links refreshed'); } + catch (e) { + if (e.status === 401) { + showLogin(); + showError('loginError', 'Your API key is no longer valid.'); + } else showToast(e.message); + } finally { $('refreshBtn').disabled = false; } +}); +$('createBtn').addEventListener('click', () => openEditor()); +$('emptyCreateBtn').addEventListener('click', () => openEditor()); +$('closeEditor').addEventListener('click', () => $('editor').close()); +$('cancelEditor').addEventListener('click', () => $('editor').close()); +$('editorForm').addEventListener('submit', saveLink); +$('deleteBtn').addEventListener('click', deleteLink); +$('code').addEventListener('input', updatePreview); +document.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !$('app').hidden) { + event.preventDefault(); + $('search').focus(); + } +}); +$('apiKey').focus(); From 39ff606d389d66deb5d7bcf23fcb738daac0295f Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:02:37 -0700 Subject: [PATCH 4/9] Host the admin SPA at /admin using ASP.NET Core static files --- src/prod/Indirection/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/prod/Indirection/Program.cs b/src/prod/Indirection/Program.cs index d1b0d40..9b1d517 100644 --- a/src/prod/Indirection/Program.cs +++ b/src/prod/Indirection/Program.cs @@ -15,10 +15,14 @@ return; } +// Serve the static admin SPA without changing API-key authorization. +app.UseDefaultFiles(); +app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health").AllowAnonymous(); +app.MapGet("/admin", () => Results.Redirect("/admin/")).AllowAnonymous(); await app.RunAsync(); public partial class Program; From 5fdbdc254af37a18846d607db581e339c560991f Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:02:39 -0700 Subject: [PATCH 5/9] Test admin static assets and unchanged API authorization --- src/test/Indirection.Tests/AdminUiTests.cs | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/test/Indirection.Tests/AdminUiTests.cs diff --git a/src/test/Indirection.Tests/AdminUiTests.cs b/src/test/Indirection.Tests/AdminUiTests.cs new file mode 100644 index 0000000..2462a4d --- /dev/null +++ b/src/test/Indirection.Tests/AdminUiTests.cs @@ -0,0 +1,32 @@ +using System.Net; + +namespace Indirection.Tests; + +[TestClass] +public sealed class AdminUiTests +{ + [TestMethod] + public async Task AdminUiIsServedWithoutExposingTheManagementApi() + { + await using var app = new AppFactory(); + using var client = app.CreateApiClient(authenticated: false); + + using var redirect = await client.GetAsync("/admin"); + Assert.AreEqual(HttpStatusCode.Redirect, redirect.StatusCode); + Assert.AreEqual("/admin/", redirect.Headers.Location?.OriginalString); + + using var index = await client.GetAsync("/admin/"); + Assert.AreEqual(HttpStatusCode.OK, index.StatusCode); + Assert.AreEqual("text/html", index.Content.Headers.ContentType?.MediaType); + StringAssert.Contains(await index.Content.ReadAsStringAsync(), "Indirection · Links"); + + foreach (var path in new[] { "/admin/styles.css", "/admin/app.js" }) + { + using var asset = await client.GetAsync(path); + Assert.AreEqual(HttpStatusCode.OK, asset.StatusCode, path); + } + + using var management = await client.GetAsync("/api/v1/links"); + Assert.AreEqual(HttpStatusCode.Unauthorized, management.StatusCode); + } +} From 2a18ea98b553518355c93d3a94ebc01f78a056b6 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 09:02:42 -0700 Subject: [PATCH 6/9] Document admin UI usage and key handling --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index e4d452a..f207a78 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,25 @@ Indirection optionally loads configuration from `/run/secrets` using ASP.NET Cor Files take precedence over all standard sources, including command-line arguments. Otherwise, standard precedence is preserved: command-line arguments, environment variables, `appsettings.{Environment}.json`, then `appsettings.json` (highest to lowest). Restart the application after rotating secret files. +### Admin UI + +The app includes a tiny, dependency-free browser UI at `/admin` (also `/admin/`). +The static HTML/CSS/JavaScript lives under `src/prod/Indirection/wwwroot/admin` +and is included in normal .NET builds, portable releases, and container images. +No Node runtime, frontend build step, database migration, or extra API endpoint is needed. + +Navigate to `https://your-indirection-host/admin` and enter the same API key +used by the management API. The UI lists, searches, creates, edits, copies, and +deletes short links using the existing `/api/v1/links` endpoints. Codes can +contain nested paths; a code itself cannot be renamed. + +The page and its static assets are publicly served; **the management API is +still protected by `X-Api-Key`**. The browser keeps the key only in page memory, +not cookies or local/session storage; refreshing or disconnecting forgets it. +Use HTTPS in production. `/admin` and `/admin/*` are reserved for the UI, +and therefore should not be used as redirect codes. The UI rejects these +and other known application route collisions. + ### Application behavior SQLite is the only supported provider. `appsettings.json` defaults to `Data Source=urls.db`; relative SQLite paths resolve against the process working directory. An absolute `ConnectionStrings__Urls` avoids ambiguity between the EF CLI and host. Put the database and its sidecar files on persistent, writable storage and back them up. Apply migrations explicitly before starting/upgrading; the application does not migrate at startup. `dotnet ef migrations has-pending-model-changes --project src/prod/Indirection` checks model drift. From 23ae900a807c80fab1429ace23d99bfa12f61837 Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 10:35:14 -0700 Subject: [PATCH 7/9] Fix admin static routing and cancel requests on disconnect Serve admin files before endpoint routing, reserve unknown admin paths, and rely on the default-file canonical redirect. Clear the API-key input and cancel stale requests when disconnecting. Add routing regressions and dependency-free UI behavior tests to CI. --- .github/workflows/ci.yml | 2 + README.md | 4 +- src/prod/Indirection/Program.cs | 5 +- src/prod/Indirection/wwwroot/admin/app.js | 31 +++- src/test/Indirection.Tests/AdminUiTests.cs | 33 ++++- src/test/admin-ui/app.test.cjs | 163 +++++++++++++++++++++ 6 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/test/admin-ui/app.test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb40bc1..d89b140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: dotnet-tools.json - run: dotnet restore Indirection.sln - run: dotnet build Indirection.sln -c Release --no-restore + - name: Test admin UI behavior + run: node --test src/test/admin-ui/app.test.cjs - name: Test and enforce line and branch coverage run: >- dotnet test --project src/test/Indirection.Tests -c Release --no-build diff --git a/README.md b/README.md index f207a78..c291eda 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ dotnet test --project src/test/Indirection.Tests -c Release --no-build \ Coverage includes all handwritten production code, including startup and authentication; only generated EF migration files are excluded. The coverage command and CI enforce at least 96% for both lines and branches. JSON and Cobertura reports are written under `artifacts/coverage`. The separate deployment suite does not contribute to coverage. The explicit SQLite native bundle reference upgrades EF's legacy transitive bundle without suppressing vulnerability warnings. +Admin UI behavior tests use Node's built-in test runner and a small DOM adapter, with no npm packages. Run `node --test src/test/admin-ui/app.test.cjs` with Node 22 or newer. These cover management operations, search/copy, validation, authentication failures, and pending requests during disconnect. Node is needed only for these tests, not to build or run the application. + Deployment verification builds independently and uses only HTTP, with automatic redirects disabled. With the local application still running (or an explicit HTTPS deployment target): ```bash @@ -96,7 +98,7 @@ dotnet test --project src/test/Indirection.DeploymentTests -c Release --no-build The suite checks health and the complete create/read/redirect/update/redirect/delete/404 lifecycle, including mixed casing, nested codes, and warmed-cache updates. Each run uses a unique `synthetic/{guid}/nested` code and cleans up in `finally`, so concurrent runs are isolated. Run this same command after deployment or on a scheduler for synthetics. When neither environment variable is supplied, deployment tests are skipped; supplying only one fails configuration. CI runs only the local suite and never targets a live deployment implicitly. -GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, and runs only `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts. +GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, runs the admin UI behavior tests, and runs `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts. Pushing a version tag matching `v*` runs the same validation before publishing a multi-platform image to GHCR and then a GitHub Release. Release creation requires successful tests and Docker publishing. After these workflow changes are committed and pushed, create the first release from the intended commit: diff --git a/src/prod/Indirection/Program.cs b/src/prod/Indirection/Program.cs index 9b1d517..f99763c 100644 --- a/src/prod/Indirection/Program.cs +++ b/src/prod/Indirection/Program.cs @@ -18,11 +18,14 @@ // Serve the static admin SPA without changing API-key authorization. app.UseDefaultFiles(); app.UseStaticFiles(); +// Select endpoints after static files; otherwise /admin/ matches an endpoint +// before the static-file middleware can serve its default document. +app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health").AllowAnonymous(); -app.MapGet("/admin", () => Results.Redirect("/admin/")).AllowAnonymous(); +app.MapGet("/admin/{**path}", () => Results.NotFound()).AllowAnonymous(); await app.RunAsync(); public partial class Program; diff --git a/src/prod/Indirection/wwwroot/admin/app.js b/src/prod/Indirection/wwwroot/admin/app.js index f658bf6..5490a61 100644 --- a/src/prod/Indirection/wwwroot/admin/app.js +++ b/src/prod/Indirection/wwwroot/admin/app.js @@ -2,7 +2,7 @@ 'use strict'; const $ = id => document.getElementById(id); -const state = { apiKey: null, links: [], editing: null, toastTimer: null }; +const state = { apiKey: null, links: [], editing: null, toastTimer: null, session: new AbortController() }; const endpoint = '/api/v1/links'; const origin = window.location.origin; @@ -12,8 +12,11 @@ function apiPath(code) { } async function request(path, options = {}) { + const { signal } = state.session; + if (!state.apiKey) throw new DOMException('Disconnected.', 'AbortError'); const headers = { 'X-Api-Key': state.apiKey, ...options.headers }; - const response = await fetch(path, { ...options, headers, cache: 'no-store' }); + const response = await fetch(path, { ...options, headers, cache: 'no-store', signal }); + signal.throwIfAborted(); if (!response.ok) { const error = new Error(response.status === 401 ? 'Invalid API key.' @@ -22,8 +25,10 @@ async function request(path, options = {}) { throw error; } if (response.status === 204) return null; - return response.headers.get('content-type')?.includes('json') - ? response.json() : null; + const result = response.headers.get('content-type')?.includes('json') + ? await response.json() : null; + signal.throwIfAborted(); + return result; } function textNode(tag, className, text) { @@ -86,6 +91,8 @@ function showApp() { } function showLogin() { + state.session.abort(); + state.session = new AbortController(); state.apiKey = null; state.links = []; state.editing = null; @@ -93,6 +100,14 @@ function showLogin() { $('headerRight').hidden = true; $('login').hidden = false; $('apiKey').value = ''; + $('apiKey').type = 'password'; + $('showKey').textContent = 'Show'; + $('showKey').setAttribute('aria-label', 'Show API key'); + $('linkRows').replaceChildren(); + $('search').value = ''; + $('editorForm').reset(); + clearTimeout(state.toastTimer); + $('toast').hidden = true; if ($('editor').open) $('editor').close(); $('apiKey').focus(); } @@ -238,6 +253,7 @@ async function saveLink(event) { $('editor').close(); showToast(state.editing ? 'Link updated' : 'Link created'); } catch (e) { + if (e.name === 'AbortError') return; if (e.status === 401) { showLogin(); showError('loginError', 'Your API key is no longer valid.'); @@ -256,6 +272,7 @@ async function deleteLink() { $('editor').close(); showToast('Link deleted'); } catch (e) { + if (e.name === 'AbortError') return; if (e.status === 401) { showLogin(); showError('loginError', 'Your API key is no longer valid.'); @@ -265,12 +282,15 @@ async function deleteLink() { $('loginForm').addEventListener('submit', async event => { event.preventDefault(); - state.apiKey = $('apiKey').value; + const apiKey = $('apiKey').value; + showLogin(); + state.apiKey = apiKey; $('connectBtn').disabled = true; $('connectBtn').textContent = 'Connecting…'; showError('loginError', ''); try { await loadLinks(); showApp(); } catch (e) { + if (e.name === 'AbortError') return; state.apiKey = null; showError('loginError', e.message || 'Unable to connect.'); } finally { @@ -290,6 +310,7 @@ $('refreshBtn').addEventListener('click', async () => { $('refreshBtn').disabled = true; try { await loadLinks(); showToast('Links refreshed'); } catch (e) { + if (e.name === 'AbortError') return; if (e.status === 401) { showLogin(); showError('loginError', 'Your API key is no longer valid.'); diff --git a/src/test/Indirection.Tests/AdminUiTests.cs b/src/test/Indirection.Tests/AdminUiTests.cs index 2462a4d..ab30b1c 100644 --- a/src/test/Indirection.Tests/AdminUiTests.cs +++ b/src/test/Indirection.Tests/AdminUiTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; namespace Indirection.Tests; @@ -12,10 +14,10 @@ public async Task AdminUiIsServedWithoutExposingTheManagementApi() using var client = app.CreateApiClient(authenticated: false); using var redirect = await client.GetAsync("/admin"); - Assert.AreEqual(HttpStatusCode.Redirect, redirect.StatusCode); - Assert.AreEqual("/admin/", redirect.Headers.Location?.OriginalString); + Assert.AreEqual(HttpStatusCode.MovedPermanently, redirect.StatusCode); + Assert.AreEqual("/admin/", redirect.Headers.Location?.AbsolutePath); - using var index = await client.GetAsync("/admin/"); + using var index = await client.GetAsync(redirect.Headers.Location); Assert.AreEqual(HttpStatusCode.OK, index.StatusCode); Assert.AreEqual("text/html", index.Content.Headers.ContentType?.MediaType); StringAssert.Contains(await index.Content.ReadAsStringAsync(), "Indirection · Links"); @@ -29,4 +31,29 @@ public async Task AdminUiIsServedWithoutExposingTheManagementApi() using var management = await client.GetAsync("/api/v1/links"); Assert.AreEqual(HttpStatusCode.Unauthorized, management.StatusCode); } + + [TestMethod] + public async Task AdminPathsAreReservedEvenWhenRedirectsExist() + { + await using var app = new AppFactory(); + using var client = app.CreateApiClient(); + await app.WithDb(db => db.Database.MigrateAsync()); + + foreach (var code in new[] { "admin", "admin/index.html", "admin/app.js", "admin/missing" }) + { + using var created = await client.PutAsJsonAsync("/api/v1/links/" + code, + new { destination = "https://example.com/reserved" }); + Assert.AreEqual(HttpStatusCode.Created, created.StatusCode); + } + + using var anonymous = app.CreateApiClient(authenticated: false); + using var index = await anonymous.GetAsync("/admin/index.html"); + Assert.AreEqual(HttpStatusCode.OK, index.StatusCode); + using var script = await anonymous.GetAsync("/admin/app.js"); + Assert.AreEqual(HttpStatusCode.OK, script.StatusCode); + using var missing = await anonymous.GetAsync("/admin/missing"); + Assert.AreEqual(HttpStatusCode.NotFound, missing.StatusCode); + using var root = await anonymous.GetAsync("/admin"); + Assert.AreEqual(HttpStatusCode.MovedPermanently, root.StatusCode); + } } diff --git a/src/test/admin-ui/app.test.cjs b/src/test/admin-ui/app.test.cjs new file mode 100644 index 0000000..37f8cb4 --- /dev/null +++ b/src/test/admin-ui/app.test.cjs @@ -0,0 +1,163 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { readFileSync } = require('node:fs'); +const { runInNewContext } = require('node:vm'); +const { resolve } = require('node:path'); + +// Exercise the shipped script with a small DOM adapter, without frontend packages. +function createApp(fetch) { + const elements = new Map(); + const element = () => ({ + value: '', hidden: false, open: false, children: [], listeners: {}, style: {}, + classList: { add() {} }, + addEventListener(name, callback) { this.listeners[name] = callback; }, + setAttribute() {}, focus() {}, reset() {}, + append(...children) { this.children.push(...children); }, + replaceChildren(...children) { this.children = children; }, + showModal() { this.open = true; }, close() { this.open = false; } + }); + const get = id => { + if (!elements.has(id)) elements.set(id, element()); + return elements.get(id); + }; + let copied; + const context = { + document: { getElementById: get, createElement: element, createElementNS: element, addEventListener() {} }, + location: { origin: 'https://short.example', hostname: 'short.example' }, + fetch, AbortController, DOMException, URL, + navigator: { clipboard: { async writeText(value) { copied = value; } } }, + confirm: () => true, setTimeout: () => 1, clearTimeout() {} + }; + context.window = { location: context.location }; + runInNewContext(readFileSync(resolve(__dirname, '../../prod/Indirection/wwwroot/admin/app.js'), 'utf8'), context); + return { + get, copied: () => copied, + async event(id, name = 'click') { await get(id).listeners[name]({ preventDefault() {} }); } + }; +} +const json = body => ({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: async () => body }); +const noContent = { ok: true, status: 204 }; +function deferred() { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} + +test('connect, search, copy, create, edit and delete use the existing management API', async () => { + const links = [{ code: 'docs/start', destination: 'https://example.com/guide', createdUtc: '2026-10-29T00:00:00Z' }]; + const calls = []; + const app = createApp(async (path, options) => { + calls.push({ path, ...options }); + assert.equal(options.headers['X-Api-Key'], 'test-key'); + if (!options.method) return json([...links]); + if (options.method === 'PUT') { + const code = decodeURIComponent(path.slice('/api/v1/links/'.length)); + const destination = JSON.parse(options.body).destination; + const existing = links.find(link => link.code === code); + if (existing) existing.destination = destination; + else links.push({ code, destination }); + } else if (options.method === 'DELETE') links.splice(links.findIndex(link => path.endsWith(link.code)), 1); + return noContent; + }); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + assert.equal(app.get('app').hidden, false); + assert.equal(app.get('apiKey').value, ''); + assert.equal(app.get('linkRows').children.length, 1); + + app.get('search').value = 'missing'; + await app.event('search', 'input'); + assert.equal(app.get('linkRows').children.length, 0); + app.get('search').value = 'guide'; + await app.event('search', 'input'); + assert.equal(app.get('linkRows').children.length, 1); + const row = app.get('linkRows').children[0]; + await row.children[3].children[0].children[0].listeners.click(); + assert.equal(app.copied(), 'https://short.example/docs/start'); + + await app.event('createBtn'); + app.get('code').value = 'team/hello world'; + app.get('destination').value = 'https://example.com/new'; + await app.event('editorForm', 'submit'); + assert.equal(calls.find(call => call.method === 'PUT').path, '/api/v1/links/team/hello%20world'); + assert.equal(links.length, 2); + await row.children[3].children[0].children[1].listeners.click(); + assert.equal(app.get('code').disabled, true); + app.get('code').value = 'docs/start'; // The DOM adapter does not implement form inputs. + app.get('destination').value = 'https://example.com/updated'; + await app.event('editorForm', 'submit'); + assert.equal(links[0].destination, 'https://example.com/updated'); + await app.event('deleteBtn'); + assert.equal(links.length, 1); + assert.equal(calls.find(call => call.method === 'DELETE').path, '/api/v1/links/docs/start'); +}); + +test('disconnect aborts a refresh and ignores a late response body', async () => { + const body = deferred(); + let calls = 0; + let refreshSignal; + const app = createApp(async (_, options) => { + if (++calls === 1) return json([]); + refreshSignal = options.signal; + return { ...json([]), json: () => body.promise }; + }); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + const refresh = app.event('refreshBtn'); + await Promise.resolve(); + await app.event('disconnectBtn'); + assert.equal(refreshSignal.aborted, true); + body.resolve([{ code: 'private', destination: 'https://example.com' }]); + await refresh; + assert.equal(app.get('app').hidden, true); + assert.equal(app.get('login').hidden, false); + assert.equal(app.get('linkRows').children.length, 0); + assert.equal(app.get('toast').hidden, true); +}); + +test('an expired key disconnects and clears rendered data', async () => { + let calls = 0; + const app = createApp(async () => ++calls === 1 + ? json([{ code: 'private', destination: 'https://example.com' }]) + : { ok: false, status: 401 }); + app.get('apiKey').value = 'expired-key'; + await app.event('loginForm', 'submit'); + await app.event('refreshBtn'); + assert.equal(app.get('login').hidden, false); + assert.equal(app.get('linkRows').children.length, 0); + assert.match(app.get('loginError').textContent, /no longer valid/); +}); + +test('a pending login cannot reconnect after disconnect', async () => { + const response = deferred(); + const app = createApp(() => response.promise); + app.get('apiKey').value = 'test-key'; + const login = app.event('loginForm', 'submit'); + await app.event('disconnectBtn'); + response.resolve(json([])); + await login; + assert.equal(app.get('app').hidden, true); + assert.equal(app.get('login').hidden, false); +}); + +test('invalid keys and reserved paths produce errors without writing a link', async () => { + const calls = []; + const app = createApp(async (path, options) => { + calls.push({ path, ...options }); + return options.headers['X-Api-Key'] === 'wrong' ? { ok: false, status: 401 } : json([]); + }); + app.get('apiKey').value = 'wrong'; + await app.event('loginForm', 'submit'); + assert.match(app.get('loginError').textContent, /Invalid API key/); + assert.equal(app.get('app').hidden, true); + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); + await app.event('createBtn'); + for (const code of ['admin', 'ADMIN/nested', 'api/v1/links', 'health', 'a/../b', 'a//b', 'a?b']) { + app.get('code').value = code; + app.get('destination').value = 'https://example.com'; + await app.event('editorForm', 'submit'); + assert.equal(app.get('editorError').hidden, false, code); + } + assert.equal(calls.filter(call => call.method).length, 0); +}); From 9b50ed988578ec5cb074491c7ec419cf71eda04e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:33 +0000 Subject: [PATCH 8/9] Fix admin review feedback Co-authored-by: Bpoe <8241635+Bpoe@users.noreply.github.com> --- src/prod/Indirection/wwwroot/admin/app.js | 60 ++++++++++++++----- src/prod/Indirection/wwwroot/admin/styles.css | 1 + 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/prod/Indirection/wwwroot/admin/app.js b/src/prod/Indirection/wwwroot/admin/app.js index 5490a61..8631f90 100644 --- a/src/prod/Indirection/wwwroot/admin/app.js +++ b/src/prod/Indirection/wwwroot/admin/app.js @@ -2,7 +2,10 @@ 'use strict'; const $ = id => document.getElementById(id); -const state = { apiKey: null, links: [], editing: null, toastTimer: null, session: new AbortController() }; +const state = { + apiKey: null, links: [], editing: null, toastTimer: null, session: new AbortController(), + pendingMutation: false, editorGeneration: 0, loadGeneration: 0 +}; const endpoint = '/api/v1/links'; const origin = window.location.origin; @@ -93,6 +96,7 @@ function showApp() { function showLogin() { state.session.abort(); state.session = new AbortController(); + state.loadGeneration++; state.apiKey = null; state.links = []; state.editing = null; @@ -113,7 +117,14 @@ function showLogin() { } async function loadLinks() { - const response = await request(endpoint); + const generation = ++state.loadGeneration; + let response; + try { response = await request(endpoint); } + catch (e) { + if (generation !== state.loadGeneration) return; + throw e; + } + if (generation !== state.loadGeneration) return; if (!Array.isArray(response)) throw new Error('The server returned an unexpected links response.'); state.links = response.sort((a, b) => a.code.localeCompare(b.code)); render(); @@ -191,13 +202,16 @@ async function copyUrl(code) { } function openEditor(link = null) { + state.editorGeneration++; state.editing = link; $('editorForm').reset(); showError('editorError', ''); $('editorEyebrow').textContent = link ? 'EDIT REDIRECT' : 'CREATE REDIRECT'; $('editorTitle').textContent = link ? 'Edit link' : 'New link'; $('saveBtn').textContent = link ? 'Save changes' : 'Create link'; + $('saveBtn').disabled = state.pendingMutation; $('deleteBtn').hidden = !link; + $('deleteBtn').disabled = state.pendingMutation; $('code').disabled = Boolean(link); $('code').value = link?.code ?? ''; $('destination').value = link?.destination ?? ''; @@ -233,15 +247,22 @@ function validateCode(code) { return ''; } +function setMutationPending(pending) { + state.pendingMutation = pending; + $('saveBtn').disabled = pending; + $('deleteBtn').disabled = pending; +} + async function saveLink(event) { event.preventDefault(); - const code = getCode(); + if (state.pendingMutation) return; + const editorGeneration = state.editorGeneration; + const code = state.editing?.code ?? getCode(); const destination = $('destination').value.trim(); const error = validateCode(code); if (error) { showError('editorError', error); return; } if (!destination) { showError('editorError', 'Enter a destination.'); return; } - const button = $('saveBtn'); - button.disabled = true; + setMutationPending(true); showError('editorError', ''); try { await request(apiPath(code), { @@ -250,34 +271,43 @@ async function saveLink(event) { body: JSON.stringify({ destination }) }); await loadLinks(); - $('editor').close(); - showToast(state.editing ? 'Link updated' : 'Link created'); + if (editorGeneration === state.editorGeneration && $('editor').open) { + $('editor').close(); + showToast(state.editing ? 'Link updated' : 'Link created'); + } } catch (e) { if (e.name === 'AbortError') return; if (e.status === 401) { showLogin(); showError('loginError', 'Your API key is no longer valid.'); - } else showError('editorError', e.message); - } finally { button.disabled = false; } + } else if (editorGeneration === state.editorGeneration && $('editor').open) { + showError('editorError', e.message); + } + } finally { setMutationPending(false); } } async function deleteLink() { + if (state.pendingMutation) return; const link = state.editing; if (!link || !confirm('Delete /' + link.code + '?\n\nThis will immediately stop the short link from redirecting.')) return; - const button = $('deleteBtn'); - button.disabled = true; + const editorGeneration = state.editorGeneration; + setMutationPending(true); try { await request(apiPath(link.code), { method: 'DELETE' }); await loadLinks(); - $('editor').close(); - showToast('Link deleted'); + if (editorGeneration === state.editorGeneration && $('editor').open) { + $('editor').close(); + showToast('Link deleted'); + } } catch (e) { if (e.name === 'AbortError') return; if (e.status === 401) { showLogin(); showError('loginError', 'Your API key is no longer valid.'); - } else showError('editorError', e.message); - } finally { button.disabled = false; } + } else if (editorGeneration === state.editorGeneration && $('editor').open) { + showError('editorError', e.message); + } + } finally { setMutationPending(false); } } $('loginForm').addEventListener('submit', async event => { diff --git a/src/prod/Indirection/wwwroot/admin/styles.css b/src/prod/Indirection/wwwroot/admin/styles.css index cb00cec..0ef984f 100644 --- a/src/prod/Indirection/wwwroot/admin/styles.css +++ b/src/prod/Indirection/wwwroot/admin/styles.css @@ -7,3 +7,4 @@ .drawer{border:0;padding:0;margin:0 0 0 auto;width:min(100%,460px);height:100dvh;max-height:100dvh;background:white;box-shadow:-15px 0 40px #0c182b14;color:#253744}.drawer::backdrop{background:rgba(19,34,45,.34)}.drawer[open]{animation:slide-in .2s ease-out}@keyframes slide-in{from{transform:translateX(20px);opacity:.8}to{transform:translateX(0);opacity:1}}.drawer-inner{height:100%;display:flex;flex-direction:column}.drawer-header{padding:33px 34px 27px;display:flex;align-items:start;justify-content:space-between;border-bottom:1px solid #edf0f2}.drawer-header h2{font-size:25px;letter-spacing:-.85px;margin:8px 0 0}.drawer-header .icon-button{margin-top:-3px}.editor-form{min-height:0;flex:1;display:flex;flex-direction:column;justify-content:space-between}.editor-fields{padding:28px 34px;overflow:auto}.editor-description{font-size:13px;line-height:1.55;color:#89949e;margin:0 0 31px}.code-field{display:flex;align-items:center;border:1px solid #dfe5e9;border-radius:7px;overflow:hidden}.code-field:focus-within{border-color:#8eabbe;box-shadow:0 0 0 3px #d9e7ef80}.code-field span{display:block;padding:11px 0 11px 13px;color:#84929d;font-size:13px}.code-field input{border:0;box-shadow:none!important;padding:12px 12px 12px 2px}.code-field input:disabled{color:#778692;background:#f9fafb}.field-help{font-size:11px;color:#a1abb3;line-height:1.5;margin-top:8px}.second-label{margin-top:28px}.editor-fields textarea{resize:vertical;line-height:1.5;min-height:92px}.preview-box{background:#f6f8f9;border:1px solid #edf1f3;border-radius:9px;margin-top:29px;padding:18px}.preview-box span{display:block;font-size:10px;letter-spacing:1px;font-weight:800;color:#9ba6af;margin-bottom:10px}.preview-box strong{font-family:ui-monospace,Consolas,monospace;color:#385c6d;font-size:12px;word-break:break-all;font-weight:650}.drawer-footer{border-top:1px solid #edf0f2;padding:20px 26px;display:flex;gap:8px}.footer-spacer{flex:1}.form-error{font-size:12px!important;color:#b44c4f!important;line-height:1.5!important;margin:13px 0 0!important}.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);z-index:10;background:#263b47;color:#fff;border-radius:8px;box-shadow:0 10px 30px #13212c2e;padding:12px 17px;font-size:12px;font-weight:660;white-space:nowrap} @media(max-width:760px){.site-header{height:65px}.header-inner{padding:0 18px}.hostname{display:none}.page{padding:35px 16px}.page-heading{margin-bottom:25px}.page-heading h1{font-size:32px}.page-heading p{font-size:13px;max-width:260px}.page-heading .button{padding:0 12px}.list-toolbar{padding:16px;align-items:stretch;flex-direction:column}.toolbar-actions{width:100%}.search-wrap{flex:1;width:auto}.shortcut{display:none}.table-scroll{overflow:visible}table,tbody{display:block;width:100%}thead{display:none}tbody tr{display:grid;grid-template-columns:1fr auto;gap:3px 10px;padding:15px 17px}tbody td{padding:0;min-width:0}tbody td:first-child{grid-column:1;grid-row:1}tbody td:nth-child(2){grid-column:1;grid-row:2;margin-left:43px}tbody td:nth-child(3){grid-column:1;grid-row:3;margin-left:43px;font-size:11px}tbody td:nth-child(4){grid-column:2;grid-row:1/span 3;align-self:center}.row-actions{gap:0}.table-footer{padding:0 17px}.table-footer span:nth-child(2){display:none}.page-footnote{line-height:1.7}.drawer-header{padding:26px 23px 23px}.editor-fields{padding:26px 23px}.drawer-footer{padding:17px 14px}.drawer-footer .button{padding:0 11px}.login{padding:54px 17px}.login-card{padding:25px}} @media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}} +.hostname,.eyebrow,.page-heading p,.search-wrap,.search-wrap input::placeholder,.shortcut,thead th,tbody td,.domain-text,.path-text,.table-footer,.page-footnote,.page-footnote code,.empty-state p,.login-kicker,.login p,.login-note,.drawer-eyebrow,.editor-description,.field-help,.code-field span,.preview-box span{color:#626d76} From 676dc36b4d444902ae19a61564cc379cd05380de Mon Sep 17 00:00:00 2001 From: Brandon Poe Date: Thu, 29 Oct 2026 21:10:04 -0700 Subject: [PATCH 9/9] Preserve cancellation when ignoring stale admin responses Reject superseded loads with AbortError so login and refresh callers cannot apply success effects after disconnect. Preserve existing aliases in previews as well as writes. Cover stale snapshots and errors, concurrent save/delete guards, reopened editor success/failure, and whitespace aliases with focused regression tests. --- src/prod/Indirection/wwwroot/admin/app.js | 8 +- src/test/admin-ui/app.test.cjs | 101 ++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/prod/Indirection/wwwroot/admin/app.js b/src/prod/Indirection/wwwroot/admin/app.js index 8631f90..9f7ab32 100644 --- a/src/prod/Indirection/wwwroot/admin/app.js +++ b/src/prod/Indirection/wwwroot/admin/app.js @@ -121,10 +121,10 @@ async function loadLinks() { let response; try { response = await request(endpoint); } catch (e) { - if (generation !== state.loadGeneration) return; + if (generation !== state.loadGeneration) throw new DOMException('Superseded request.', 'AbortError'); throw e; } - if (generation !== state.loadGeneration) return; + if (generation !== state.loadGeneration) throw new DOMException('Superseded request.', 'AbortError'); if (!Array.isArray(response)) throw new Error('The server returned an unexpected links response.'); state.links = response.sort((a, b) => a.code.localeCompare(b.code)); render(); @@ -229,7 +229,7 @@ function updatePreview() { } function getCode() { - return $('code').value.trim().replace(/^\/+|\/+$/g, ''); + return state.editing?.code ?? $('code').value.trim().replace(/^\/+|\/+$/g, ''); } function validateCode(code) { @@ -257,7 +257,7 @@ async function saveLink(event) { event.preventDefault(); if (state.pendingMutation) return; const editorGeneration = state.editorGeneration; - const code = state.editing?.code ?? getCode(); + const code = getCode(); const destination = $('destination').value.trim(); const error = validateCode(code); if (error) { showError('editorError', error); return; } diff --git a/src/test/admin-ui/app.test.cjs b/src/test/admin-ui/app.test.cjs index 37f8cb4..6bab002 100644 --- a/src/test/admin-ui/app.test.cjs +++ b/src/test/admin-ui/app.test.cjs @@ -161,3 +161,104 @@ test('invalid keys and reserved paths produce errors without writing a link', as } assert.equal(calls.filter(call => call.method).length, 0); }); + +async function connect(app) { + app.get('apiKey').value = 'test-key'; + await app.event('loginForm', 'submit'); +} +async function editRow(app, index = 0) { + await app.get('linkRows').children[index].children[3].children[0].children[1].listeners.click(); +} +const existingLink = { code: 'docs/start', destination: 'https://example.com/guide' }; + +for (const staleResponse of ['snapshot', 'unauthorized']) { + test(`a superseded refresh ${staleResponse} cannot undo a completed creation`, async () => { + const oldRefresh = deferred(); + let reads = 0; + const app = createApp(async (_, options) => { + if (options.method === 'PUT') return noContent; + if (++reads === 2) return oldRefresh.promise; + return json(reads === 1 ? [existingLink] : [existingLink, { code: 'new', destination: 'https://example.com/new' }]); + }); + await connect(app); + const refresh = app.event('refreshBtn'); + await app.event('createBtn'); + app.get('code').value = 'new'; + app.get('destination').value = 'https://example.com/new'; + await app.event('editorForm', 'submit'); + assert.equal(app.get('linkRows').children.length, 2); + oldRefresh.resolve(staleResponse === 'snapshot' ? json([existingLink]) : { ok: false, status: 401 }); + await refresh; + assert.equal(app.get('linkRows').children.length, 2); + assert.equal(app.get('app').hidden, false); + assert.equal(app.get('toast').textContent, 'Link created'); + }); +} + +for (const operation of ['save', 'delete']) { + test(`a pending ${operation} prevents competing writes`, async () => { + const mutation = deferred(); + const writes = []; + const app = createApp(async (_, options) => { + if (!options.method) return json([existingLink]); + writes.push(options.method); + return mutation.promise; + }); + await connect(app); + await editRow(app); + const pending = operation === 'save' ? app.event('editorForm', 'submit') : app.event('deleteBtn'); + assert.equal(app.get('saveBtn').disabled, true); + assert.equal(app.get('deleteBtn').disabled, true); + // Invoke handlers directly too: disabled controls alone are insufficient. + await app.event('editorForm', 'submit'); + await app.event('deleteBtn'); + assert.deepEqual(writes, [operation === 'save' ? 'PUT' : 'DELETE']); + mutation.resolve(noContent); + await pending; + assert.equal(app.get('saveBtn').disabled, false); + assert.equal(app.get('deleteBtn').disabled, false); + }); +} + +for (const outcome of ['success', 'failure']) { + test(`a previous save's ${outcome} cannot affect a reopened editor`, async () => { + const mutation = deferred(); + const app = createApp(async (_, options) => options.method ? mutation.promise : json([existingLink])); + await connect(app); + await editRow(app); + const save = app.event('editorForm', 'submit'); + await app.event('cancelEditor'); + await app.event('createBtn'); + app.get('code').value = 'another'; + app.get('destination').value = 'https://example.com/another'; + assert.equal(app.get('saveBtn').disabled, true); + mutation.resolve(outcome === 'success' ? noContent : { ok: false, status: 500 }); + await save; + assert.equal(app.get('editor').open, true); + assert.equal(app.get('code').value, 'another'); + assert.equal(app.get('destination').value, 'https://example.com/another'); + assert.equal(app.get('editorError').hidden, true); + assert.equal(app.get('saveBtn').disabled, false); + }); +} + +test('editing preserves an existing alias exactly in previews and write paths', async () => { + const link = { code: 'docs ', destination: 'https://example.com/original' }; + const writes = []; + const app = createApp(async (path, options) => { + if (!options.method) return json([link]); + writes.push({ path, ...options }); + return noContent; + }); + await connect(app); + await editRow(app); + assert.equal(app.get('previewUrl').textContent, 'https://short.example/docs%20'); + app.get('code').value = 'docs'; // Even a changed disabled input must not rename the alias. + app.get('destination').value = 'https://example.com/updated'; + await app.event('editorForm', 'submit'); + assert.equal(writes[0].path, '/api/v1/links/docs%20'); + assert.equal(JSON.parse(writes[0].body).destination, 'https://example.com/updated'); + await editRow(app); + await app.event('deleteBtn'); + assert.equal(writes[1].path, '/api/v1/links/docs%20'); +});