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 e4d452a..c291eda 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.
@@ -66,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
@@ -77,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 d1b0d40..f99763c 100644
--- a/src/prod/Indirection/Program.cs
+++ b/src/prod/Indirection/Program.cs
@@ -15,10 +15,17 @@
return;
}
+// 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/{**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
new file mode 100644
index 0000000..9f7ab32
--- /dev/null
+++ b/src/prod/Indirection/wwwroot/admin/app.js
@@ -0,0 +1,363 @@
+/* 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, session: new AbortController(),
+ pendingMutation: false, editorGeneration: 0, loadGeneration: 0
+};
+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 { 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', signal });
+ signal.throwIfAborted();
+ 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;
+ const result = response.headers.get('content-type')?.includes('json')
+ ? await response.json() : null;
+ signal.throwIfAborted();
+ return result;
+}
+
+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.session.abort();
+ state.session = new AbortController();
+ state.loadGeneration++;
+ state.apiKey = null;
+ state.links = [];
+ state.editing = null;
+ $('app').hidden = true;
+ $('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();
+}
+
+async function loadLinks() {
+ const generation = ++state.loadGeneration;
+ let response;
+ try { response = await request(endpoint); }
+ catch (e) {
+ if (generation !== state.loadGeneration) throw new DOMException('Superseded request.', 'AbortError');
+ throw e;
+ }
+ 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();
+}
+
+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.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 ?? '';
+ $('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 state.editing?.code ?? $('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 '';
+}
+
+function setMutationPending(pending) {
+ state.pendingMutation = pending;
+ $('saveBtn').disabled = pending;
+ $('deleteBtn').disabled = pending;
+}
+
+async function saveLink(event) {
+ event.preventDefault();
+ if (state.pendingMutation) return;
+ const editorGeneration = state.editorGeneration;
+ 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; }
+ setMutationPending(true);
+ showError('editorError', '');
+ try {
+ await request(apiPath(code), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ destination })
+ });
+ await loadLinks();
+ 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 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 editorGeneration = state.editorGeneration;
+ setMutationPending(true);
+ try {
+ await request(apiPath(link.code), { method: 'DELETE' });
+ await loadLinks();
+ 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 if (editorGeneration === state.editorGeneration && $('editor').open) {
+ showError('editorError', e.message);
+ }
+ } finally { setMutationPending(false); }
+}
+
+$('loginForm').addEventListener('submit', async event => {
+ event.preventDefault();
+ 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 {
+ $('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.name === 'AbortError') return;
+ 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();
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.
+
+
+
+
+
+
+
+
+
+
No links yet
+
Create your first link to get started.
+
+
+
+
+
+
+
+
+
+
+ INDIRECTION ADMIN
+ Connect to your links
+ Enter your management API key to view and edit the redirects on this server.
+
+ Your key is kept only in this page's memory and is forgotten when you refresh or disconnect.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/prod/Indirection/wwwroot/admin/styles.css b/src/prod/Indirection/wwwroot/admin/styles.css
new file mode 100644
index 0000000..0ef984f
--- /dev/null
+++ b/src/prod/Indirection/wwwroot/admin/styles.css
@@ -0,0 +1,10 @@
+: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}}
+.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}
diff --git a/src/test/Indirection.Tests/AdminUiTests.cs b/src/test/Indirection.Tests/AdminUiTests.cs
new file mode 100644
index 0000000..ab30b1c
--- /dev/null
+++ b/src/test/Indirection.Tests/AdminUiTests.cs
@@ -0,0 +1,59 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+
+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.MovedPermanently, redirect.StatusCode);
+ Assert.AreEqual("/admin/", redirect.Headers.Location?.AbsolutePath);
+
+ 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");
+
+ 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);
+ }
+
+ [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..6bab002
--- /dev/null
+++ b/src/test/admin-ui/app.test.cjs
@@ -0,0 +1,264 @@
+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);
+});
+
+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');
+});