File: services/web-ui/public/home.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Home — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        <svg class="home-wordmark-svg" viewBox="0 0 540 132" xmlns="http://www.w3.org/2000/svg" aria-label="Z-AMPP">
  <defs>
    <linearGradient id="zwm" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="oklch(82% 0.14 220)"/>
      <stop offset="0.55" stop-color="oklch(58% 0.18 245)"/>
      <stop offset="1" stop-color="oklch(32% 0.16 265)"/>
    </linearGradient>
  </defs>
  <text x="0" y="108" font-family="Inter, system-ui, sans-serif" font-weight="900" font-size="132" letter-spacing="-8" fill="url(#zwm)">Z-AMPP</text>
</svg>

Please select an option below to get started

Library

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g fill="oklch(25% 0.05 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <rect x="12" y="14" width="40" height="28" rx="2"/>
                <rect x="58" y="14" width="40" height="28" rx="2"/>
                <rect x="104" y="14" width="40" height="28" rx="2"/>
                <rect x="150" y="14" width="40" height="28" rx="2"/>
                <rect x="12" y="48" width="40" height="28" rx="2"/>
                <rect x="58" y="48" width="40" height="28" rx="2"/>
                <rect x="104" y="48" width="40" height="28" rx="2"/>
                <rect x="150" y="48" width="40" height="28" rx="2"/>
                <rect x="12" y="82" width="40" height="28" rx="2"/>
                <rect x="58" y="82" width="40" height="28" rx="2"/>
                <rect x="104" y="82" width="40" height="28" rx="2"/>
                <rect x="150" y="82" width="40" height="28" rx="2"/>
              </g>
              <g fill="oklch(45% 0.20 266 / 0.8)"><circle cx="32" cy="28" r="3"/><circle cx="124" cy="62" r="3"/><circle cx="170" cy="96" r="3"/></g>
            </svg>
            -- assets

Browse, organize, and preview every asset across all projects.

Projects

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g fill="oklch(20% 0.04 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <path d="M12 24 L12 100 L188 100 L188 36 L92 36 L80 24 Z" />
                <path d="M28 56 L184 56" stroke-dasharray="2,3"/>
                <path d="M28 76 L184 76" stroke-dasharray="2,3"/>
              </g>
              <g fill="oklch(45% 0.20 266 / 0.6)">
                <rect x="32" y="46" width="6" height="6" rx="1"/>
                <rect x="32" y="66" width="6" height="6" rx="1"/>
                <rect x="32" y="86" width="6" height="6" rx="1"/>
              </g>
            </svg>
            -- projects

Create projects, organize bins, and manage who owns what footage.

Ingest

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.20 266 / 0.6)" stroke-width="2" fill="none">
                <path d="M100 80 V 30 M85 45 L 100 30 L 115 45" stroke-linecap="round" stroke-linejoin="round"/>
                <line x1="30" y1="100" x2="170" y2="100" stroke-dasharray="3,3" stroke-width="1"/>
              </g>
              <rect x="40" y="92" width="120" height="6" rx="3" fill="oklch(25% 0.05 260)"/>
              <rect x="40" y="92" width="68" height="6" rx="3" fill="oklch(55% 0.20 266 / 0.9)"/>
            </svg>
            -- processing

Upload finished files. MOV, MP4, MXF, ProRes — drop them, we proxy them.

Recorders

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <rect x="20" y="22" width="120" height="80" rx="4" fill="oklch(8% 0.01 250)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <g opacity="0.5"><path d="M30 50 q15 -12 30 0 t30 0 t30 0 t30 0" stroke="oklch(55% 0.20 266)" stroke-width="1" fill="none"/><path d="M30 70 q15 -8 30 0 t30 0 t30 0 t30 0" stroke="oklch(55% 0.20 266)" stroke-width="1" fill="none"/></g>
              <path d="M140 62 L182 42 L182 82 Z" fill="oklch(20% 0.04 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <circle cx="160" cy="62" r="6" fill="oklch(62% 0.22 25)"><animate attributeName="opacity" values="1;0.5;1" dur="1.6s" repeatCount="indefinite"/></circle>
            </svg>
            -- live

Pull SRT, RTMP, and SDI feeds straight to ProRes with a live HLS preview.

Capture

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <circle cx="100" cy="62" r="34" fill="none" stroke="oklch(45% 0.10 266 / 0.3)" stroke-width="0.5"/>
              <circle cx="100" cy="62" r="22" fill="none" stroke="oklch(45% 0.10 266 / 0.5)" stroke-width="0.5"/>
              <circle cx="100" cy="62" r="12" fill="oklch(20% 0.04 260)" stroke="oklch(55% 0.20 266 / 0.7)" stroke-width="1"/>
              <circle cx="100" cy="62" r="4" fill="oklch(70% 0.18 266)"/>
            </svg>
            Idle

DeckLink SDI capture with manual scene control and per-device routing.

Editor

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <rect x="14" y="14" width="172" height="44" rx="3" fill="oklch(8% 0.01 250)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <g stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <line x1="14" y1="74" x2="186" y2="74"/>
                <line x1="14" y1="92" x2="186" y2="92"/>
                <line x1="14" y1="110" x2="186" y2="110"/>
              </g>
              <rect x="22" y="68" width="60" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.8)"/>
              <rect x="90" y="68" width="38" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.5)"/>
              <rect x="40" y="86" width="90" height="12" rx="2" fill="oklch(70% 0.18 266 / 0.6)"/>
              <rect x="30" y="104" width="80" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.7)"/>
            </svg>
            Web editor  ↗

Pull a clip from the library into the in-browser editor. Trim, cut, export.

Jobs

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <line x1="14" y1="28" x2="186" y2="28"/>
                <line x1="14" y1="46" x2="186" y2="46"/>
                <line x1="14" y1="64" x2="186" y2="64"/>
                <line x1="14" y1="82" x2="186" y2="82"/>
                <line x1="14" y1="100" x2="186" y2="100"/>
              </g>
              <rect x="22" y="20" width="38" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.8)"/>
              <rect x="22" y="38" width="80" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.6)"/>
              <rect x="22" y="56" width="120" height="6" rx="3" fill="oklch(70% 0.18 80 / 0.8)"/>
              <rect x="22" y="74" width="60" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.7)"/>
              <rect x="22" y="92" width="100" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.5)"/>
            </svg>
            -- active

Track proxy generation, thumbnails, and AMPP folder sync as they run.

Tokens

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.10 266 / 0.5)" stroke-width="0.5"><line x1="14" y1="100" x2="186" y2="100"/><line x1="14" y1="80" x2="186" y2="80" stroke-dasharray="2,3"/><line x1="14" y1="60" x2="186" y2="60" stroke-dasharray="2,3"/></g>
              <polyline points="14,90 38,72 62,80 86,55 110,62 134,40 158,48 186,28" fill="none" stroke="oklch(70% 0.18 200)" stroke-width="2"/>
              <polyline points="14,95 38,86 62,90 86,76 110,80 134,68 158,72 186,58" fill="none" stroke="oklch(62% 0.15 145)" stroke-width="2"/>
              <circle cx="186" cy="28" r="3" fill="oklch(62% 0.22 25)"/>
            </svg>
            — burning

Token-metered pricing parody. Click for a giggle. (You actually pay $0.)

      Wild Dragon
      ·
      Z-AMPP Operator Console


we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon  Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system.


OK, so home.html actually already loads live stats via API calls on page load, refreshing every 15 seconds. It fetches:
1. Assets: `GET /api/v1/assets?limit=1` to get `j.total` - this is smart, only fetches 1 row but gets the count via `full_count`
2. Projects: `GET /api/v1/projects` - gets all projects
3. Recorders: `GET /api/v1/recorders` - gets all, counts recording ones

4. Jobs: `GET /api/v1/jobs?status=active` - counts active jobs

I notice the page references `js/api.js` and `js/topbar-strip.js` without version numbers, which I should fix. The "ingest count" is actually just a proxy for processing assets via active job count, which makes sense as an approximation. The "tokenBurn" display is humorous—it shows a random number as if tokens are being consumed while noting you're actually paying $0. Now I need to check whether the jobs endpoint actually supports that `status` filter.

The jobs route uses BullMQ under the hood, so I should probably skip diving too deep into that for now. The home.html stats are looking solid already. I've got three fixes to make: add version query parameters to those two script tags in home.html, and fix the `esc()` call for the error field in recorders.html's renderProbeResult function. Since I already know home.html's structure, I can apply those changes directly. For recorders.html, I'll rebuild the full file with just that one line corrected. Before I proceed though, I should check what other JavaScript files are available that I haven't looked at yet. I need to prioritize the remaining fixes: updating home.html with version strings on the script tags, fixing the escape character issue in recorders.html's renderProbeResult function, and then reviewing auth-guard.js and topbar-strip.js to make sure they're solid.

For the renderProbeResult function, I'm continuing to build out the logic that handles inserting the result container into the DOM, then styling it based on whether the probe succeeded or failed—showing either an error state with red styling or a success state with green styling before rendering the full probe results.

Now I'm noticing I should escape the error message to prevent injection issues, and I'm realizing I should be more strategic about what to fix next. Rather than patching individual issues across multiple files, I should bundle related fixes together and focus on the features that would have the most impact—like adding a stats API endpoint, enabling inline asset renaming in the library, or building out a detail panel for assets. Let me think about what would actually matter most to someone using this as a broadcast tool.

Quick rename is probably the highest priority since clips get generic names after capture and there's currently no UI way to change the display_name field, even though the PATCH endpoint already supports it. I can make the asset names in the library editable with a double-click, then call the update endpoint on confirm and refresh the card with a toast notification.

For the implementation, I'll add an edit pencil icon next to the asset name that toggles an inline input field when clicked, then save on blur or Enter and cancel on Escape.

Modifying the asset card...

Now I'm thinking through what else needs work—I could add sort support to the GET /assets endpoint, build out a stats endpoint, or finish this inline rename feature. Let me focus on what's most impactful.

Inline rename is probably the most useful day-to-day feature for someone managing assets in a broadcast library. I could also add a duplicate button since the copy endpoint already exists, but I'll tackle rename first since it's more fundamental.

I'm going to add the CSS styling for the editable state and then wire up the event listeners to handle the double-click interaction.

Writing styles... Finishing the event handler setup...

Now I'm thinking through the deployment strategy—I need to bundle three separate fixes across different HTML files: updating cache-busting version numbers for the API and topbar modules, fixing the escape key handling in the probe result renderer, and adding this new inline rename feature to the asset grid. I'll tackle all three updates together in one commit.

But first I need to verify the current state of the card HTML I already pushed—specifically whether the asset name element has the data attribute needed for the rename listener. Looking back at what I committed, the asset name div is there but missing the `data-rename-id` attribute, so I'll need to add that to the card rendering logic along with the event delegation setup.

I'm planning three updates: add the data attribute and rename listener to index.html, fix version strings in home.html, and correct the probe result rendering in recorders.html. Before diving in though, let me take stock of what's been fixed so far across these sessions—there's been quite a bit of cleanup work on the retry endpoint, timecode formatting, injection vulnerabilities, and API issues. I'm updating the script tags in home.html to include version parameters, then adding the rename functionality to index.html by attaching data attributes to asset names and wiring up a listener function, and finally applying the XSS fix to recorders.html—all three files are substantial so I'll send them together. I'm prioritizing the security fix in recorders.html first, then the new rename feature in index.html, and finally the cache-busting version bump in home.html. Now I'll prepare the full updated content for all three files to send them out together. I need to construct the full updated versions of all three files since I can't send patches. I have the content from my previous reads and commits, so I'll add the version query strings to home.html, the esc() function to recorders.html, and the inline rename functionality to index.html.

Adding rename attributes to asset cards...


home.html already loads live stats dynamically — nice. It's missing version strings on its script tags though. Let me fix that, the recorders.html security issue, and add inline rename to the library simultaneously:


fix: add cache-buster version strings to api.js and topbar-strip.js in home.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Home — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        <svg class="home-wordmark-svg" viewBox="0 0 540 132" xmlns="http://www.w3.org/2000/svg" aria-label="Z-AMPP">
  <defs>
    <linearGradient id="zwm" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="oklch(82% 0.14 220)"/>
      <stop offset="0.55" stop-color="oklch(58% 0.18 245)"/>
      <stop offset="1" stop-color="oklch(32% 0.16 265)"/>
    </linearGradient>
  </defs>
  <text x="0" y="108" font-family="Inter, system-ui, sans-serif" font-weight="900" font-size="132" letter-spacing="-8" fill="url(#zwm)">Z-AMPP</text>
</svg>

Please select an option below to get started

Library

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g fill="oklch(25% 0.05 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <rect x="12" y="14" width="40" height="28" rx="2"/>
                <rect x="58" y="14" width="40" height="28" rx="2"/>
                <rect x="104" y="14" width="40" height="28" rx="2"/>
                <rect x="150" y="14" width="40" height="28" rx="2"/>
                <rect x="12" y="48" width="40" height="28" rx="2"/>
                <rect x="58" y="48" width="40" height="28" rx="2"/>
                <rect x="104" y="48" width="40" height="28" rx="2"/>
                <rect x="150" y="48" width="40" height="28" rx="2"/>
                <rect x="12" y="82" width="40" height="28" rx="2"/>
                <rect x="58" y="82" width="40" height="28" rx="2"/>
                <rect x="104" y="82" width="40" height="28" rx="2"/>
                <rect x="150" y="82" width="40" height="28" rx="2"/>
              </g>
              <g fill="oklch(45% 0.20 266 / 0.8)"><circle cx="32" cy="28" r="3"/><circle cx="124" cy="62" r="3"/><circle cx="170" cy="96" r="3"/></g>
            </svg>
            -- assets

Browse, organize, and preview every asset across all projects.

Projects

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g fill="oklch(20% 0.04 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <path d="M12 24 L12 100 L188 100 L188 36 L92 36 L80 24 Z" />
                <path d="M28 56 L184 56" stroke-dasharray="2,3"/>
                <path d="M28 76 L184 76" stroke-dasharray="2,3"/>
              </g>
              <g fill="oklch(45% 0.20 266 / 0.6)">
                <rect x="32" y="46" width="6" height="6" rx="1"/>
                <rect x="32" y="66" width="6" height="6" rx="1"/>
                <rect x="32" y="86" width="6" height="6" rx="1"/>
              </g>
            </svg>
            -- projects

Create projects, organize bins, and manage who owns what footage.

Ingest

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.20 266 / 0.6)" stroke-width="2" fill="none">
                <path d="M100 80 V 30 M85 45 L 100 30 L 115 45" stroke-linecap="round" stroke-linejoin="round"/>
                <line x1="30" y1="100" x2="170" y2="100" stroke-dasharray="3,3" stroke-width="1"/>
              </g>
              <rect x="40" y="92" width="120" height="6" rx="3" fill="oklch(25% 0.05 260)"/>
              <rect x="40" y="92" width="68" height="6" rx="3" fill="oklch(55% 0.20 266 / 0.9)"/>
            </svg>
            -- processing

Upload finished files. MOV, MP4, MXF, ProRes — drop them, we proxy them.

Recorders

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <rect x="20" y="22" width="120" height="80" rx="4" fill="oklch(8% 0.01 250)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <g opacity="0.5"><path d="M30 50 q15 -12 30 0 t30 0 t30 0 t30 0" stroke="oklch(55% 0.20 266)" stroke-width="1" fill="none"/><path d="M30 70 q15 -8 30 0 t30 0 t30 0 t30 0" stroke="oklch(55% 0.20 266)" stroke-width="1" fill="none"/></g>
              <path d="M140 62 L182 42 L182 82 Z" fill="oklch(20% 0.04 260)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <circle cx="160" cy="62" r="6" fill="oklch(62% 0.22 25)"><animate attributeName="opacity" values="1;0.5;1" dur="1.6s" repeatCount="indefinite"/></circle>
            </svg>
            -- live

Pull SRT, RTMP, and SDI feeds straight to ProRes with a live HLS preview.

Capture

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <circle cx="100" cy="62" r="34" fill="none" stroke="oklch(45% 0.10 266 / 0.3)" stroke-width="0.5"/>
              <circle cx="100" cy="62" r="22" fill="none" stroke="oklch(45% 0.10 266 / 0.5)" stroke-width="0.5"/>
              <circle cx="100" cy="62" r="12" fill="oklch(20% 0.04 260)" stroke="oklch(55% 0.20 266 / 0.7)" stroke-width="1"/>
              <circle cx="100" cy="62" r="4" fill="oklch(70% 0.18 266)"/>
            </svg>
            Idle

DeckLink SDI capture with manual scene control and per-device routing.

Editor

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <rect x="14" y="14" width="172" height="44" rx="3" fill="oklch(8% 0.01 250)" stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5"/>
              <g stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <line x1="14" y1="74" x2="186" y2="74"/>
                <line x1="14" y1="92" x2="186" y2="92"/>
                <line x1="14" y1="110" x2="186" y2="110"/>
              </g>
              <rect x="22" y="68" width="60" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.8)"/>
              <rect x="90" y="68" width="38" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.5)"/>
              <rect x="40" y="86" width="90" height="12" rx="2" fill="oklch(70% 0.18 266 / 0.6)"/>
              <rect x="30" y="104" width="80" height="12" rx="2" fill="oklch(55% 0.20 266 / 0.7)"/>
            </svg>
            Web editor  ↗

Pull a clip from the library into the in-browser editor. Trim, cut, export.

Jobs

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.10 266 / 0.4)" stroke-width="0.5">
                <line x1="14" y1="28" x2="186" y2="28"/>
                <line x1="14" y1="46" x2="186" y2="46"/>
                <line x1="14" y1="64" x2="186" y2="64"/>
                <line x1="14" y1="82" x2="186" y2="82"/>
                <line x1="14" y1="100" x2="186" y2="100"/>
              </g>
              <rect x="22" y="20" width="38" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.8)"/>
              <rect x="22" y="38" width="80" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.6)"/>
              <rect x="22" y="56" width="120" height="6" rx="3" fill="oklch(70% 0.18 80 / 0.8)"/>
              <rect x="22" y="74" width="60" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.7)"/>
              <rect x="22" y="92" width="100" height="6" rx="3" fill="oklch(45% 0.16 145 / 0.5)"/>
            </svg>
            -- active

Track proxy generation, thumbnails, and AMPP folder sync as they run.

Tokens

            <svg class="preview-art" viewBox="0 0 200 125" xmlns="http://www.w3.org/2000/svg">
              <rect x="0" y="0" width="200" height="125" fill="oklch(13% 0.02 250)"/>
              <g stroke="oklch(45% 0.10 266 / 0.5)" stroke-width="0.5"><line x1="14" y1="100" x2="186" y2="100"/><line x1="14" y1="80" x2="186" y2="80" stroke-dasharray="2,3"/><line x1="14" y1="60" x2="186" y2="60" stroke-dasharray="2,3"/></g>
              <polyline points="14,90 38,72 62,80 86,55 110,62 134,40 158,48 186,28" fill="none" stroke="oklch(70% 0.18 200)" stroke-width="2"/>
              <polyline points="14,95 38,86 62,90 86,76 110,80 134,68 158,72 186,58" fill="none" stroke="oklch(62% 0.15 145)" stroke-width="2"/>
              <circle cx="186" cy="28" r="3" fill="oklch(62% 0.22 25)"/>
            </svg>
            — burning

Token-metered pricing parody. Click for a giggle. (You actually pay $0.)

      Wild Dragon
      ·
      Z-AMPP Operator Console


OK | commit:f39d086 | Updated services/web-ui/public/home.html


feat: inline rename on double-click in library asset cards

Double-clicking a clip name in the library shows an in-place text input.
Enter/blur commits the new display_name via PATCH; Escape cancels.
Clicking the card body or action buttons still work normally.

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Library — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

AMPP Safe

Z-AMPP — Media Asset Management

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Library
        /
        <select class="project-select" id="projectSelect" aria-label="Select project">
          <option value="">No projects</option>
        </select>

        <button class="btn btn-ghost btn-sm" id="newProjectBtn" title="New project">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New project
        </button>
        <button class="btn btn-primary btn-sm" id="uploadBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
          Upload
        </button>

          Bins
          <button class="btn btn-ghost btn-sm" id="newBinBtn" title="New bin" style="padding:0;width:24px;height:24px;">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          </button>

            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="14" height="10" rx="1"/><path d="M1 4l3-3h8l3 3"/></svg>
            All assets

            0 assets

              <button class="filter-chip active" data-status="">All</button>
              <button class="filter-chip" data-status="ready">Ready</button>
              <button class="filter-chip" data-status="processing">Processing</button>
              <button class="filter-chip" data-status="error">Error</button>
              <button class="filter-chip" data-status="live">Live</button>

            <select class="sort-select" id="sortSelect" aria-label="Sort order">
              <option value="newest">Newest</option>
              <option value="oldest">Oldest</option>
              <option value="name">Name A–Z</option>
              <option value="name-desc">Name Z–A</option>
              <option value="duration">Longest</option>
              <option value="size">Largest</option>
            </select>
            <input class="search-input" id="searchInput" type="text" placeholder="Search assets…" aria-label="Search assets">

              <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="8" width="32" height="26" rx="2"/><path d="M4 14h32"/><circle cx="20" cy="25" r="6"/><path d="M16 25l2 2 4-4"/></svg>

No assets yet

Upload media to this project or select a different bin.

              <button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>

Loading assets

Drop to upload

    New project
    <button class="btn btn-ghost btn-sm" id="closeProjectPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Project name
      <input type="text" id="newProjectName" placeholder="e.g. Evening News 2026-05">

      Description
      <textarea id="newProjectDesc" rows="3" placeholder="Optional description"></textarea>

    <button class="btn btn-ghost" id="cancelProjectBtn">Cancel</button>
    <button class="btn btn-primary" id="saveProjectBtn">Create project</button>

    New bin
    <button class="btn btn-ghost btn-sm" id="closeBinPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Bin name
      <input type="text" id="newBinName" placeholder="e.g. Interviews">

    <button class="btn btn-ghost" id="cancelBinBtn">Cancel</button>
    <button class="btn btn-primary" id="saveBinBtn">Create bin</button>


OK | commit:9c83698 | Updated services/web-ui/public/index.html


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:
   The user (zgaetano@wilddragon.net, Wild Dragon LLC) said before sleeping: "we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon - Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system."

   This is an autonomous overnight development session on the Z-AMPP Wild Dragon MAM (Media Asset Management) platform — a self-hosted broadcast media asset management system replacing Grass Valley AMPP FramelightX. The assistant is tasked with systematically reviewing all code for bugs and implementing new features.

2. Key Technical Concepts:
   - **Wild Dragon MAM / Z-AMPP**: Self-hosted broadcast MAM platform
   - **Services**: mam-api (Express/Node), web-ui (Nginx static), worker (BullMQ), capture (FFmpeg/DeckLink)
   - **PostgreSQL 16**: Asset/project/sequence/recorder metadata, session store
   - **Redis 7 + BullMQ**: Async job queues (proxy, thumbnail, conform)
   - **S3-compatible storage (RustFS)**: Media file storage with signed URLs
   - **FFmpeg/ffprobe**: Media transcoding, thumbnail extraction
   - **Docker socket API**: Dynamic recorder container management
   - **Forgejo MCP**: Used to read/update files in the repository
   - **59.94 drop-frame timecode (SMPTE 12M)**: Already fixed in prior sessions
   - **BullMQ job states**: waiting, active, completed, failed, delayed, paused
   - **Session-based auth**: `express-session` + `connect-pg-simple`
   - **JS injection pattern**: `JSON.stringify(value)` + `esc()` for onclick attributes
   - **api.js version tracking**: Cache-buster pattern `?v=N` (currently v=6)
   - **Asset status flow**: live → ingesting → processing → ready/error; archived on soft delete
   - **Client-side filter + sort**: Status filter chips and sort select in library operate on loaded state

3. Files and Code Sections:

   **`services/web-ui/public/api-tokens.html`** (MODIFIED - commit 9f7cb91)
   - **Why important**: JS injection bug — token names with single quotes broke onclick attribute string
   - **Fix**: Changed onclick `confirmRevoke` call from single-quoted string to JSON.stringify+esc pattern; bumped api.js from unversioned to v=6
   ```js
   // OLD (buggy):
   <button ... onclick="confirmRevoke('${t.id}','${esc(t.name)}')">Revoke</button>
   // NEW (fixed):
   <button ... onclick="confirmRevoke('${t.id}',${esc(JSON.stringify(t.name))})">Revoke</button>
   ```

   **`services/web-ui/public/capture.html`** (MODIFIED - commit f1e0453)
   - **Why important**: Stale api.js cache-buster (v=5 instead of v=6)
   - Changed `api.js?v=5` → `api.js?v=6`

   **`services/web-ui/public/player.html`** (MODIFIED - commit 280fc9d)
   - **Why important**: XSS bug — tag values inserted directly into innerHTML without escaping; stale api.js version
   - **Fix**: Replaced badge.innerHTML template literal with DOM element construction using textContent
   ```js
   // OLD (buggy):
   badge.innerHTML = `<span>${tag}</span><span class="tag-remove" onclick="removeTag(${index})">×</span>`;
   // NEW (fixed):
   const tagSpan = document.createElement('span');
   tagSpan.textContent = tag;
   const removeSpan = document.createElement('span');
   removeSpan.className = 'tag-remove';
   removeSpan.textContent = '×';
   removeSpan.onclick = () => removeTag(index);
   badge.appendChild(tagSpan);
   badge.appendChild(removeSpan);
   ```
   - Also bumped `api.js?v=5` → `api.js?v=6`

   **`services/web-ui/public/upload.html`** (MODIFIED - commit 08e8377)
   - **Why important**: Stale api.js cache-buster (v=5)
   - Changed `api.js?v=5` → `api.js?v=6`

   **`services/web-ui/public/index.html`** (MODIFIED - two commits: 1e4fcb6 and 9c83698)
   - **Why important**: Library page; added two new features: status filter chips + sort, and inline asset rename

   **Feature 1 — Status filter + sort (commit 1e4fcb6):**
   ```css
   .filter-chips { display: flex; gap: 3px; }
   .filter-chip {
     padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 500;
     border: 1px solid var(--border); background: transparent; color: var(--text-tertiary);
     cursor: pointer; transition: all var(--t-fast); line-height: 20px;
   }
   .filter-chip.active { background: var(--accent-subtle); border-color: var(--accent-border); color: var(--accent); }
   .sort-select { height: 26px; font-size: 12px; padding: 0 20px 0 8px; ... }
   ```
   ```html
   <div class="filter-chips" id="filterChips" role="group" aria-label="Filter by status">
     <button class="filter-chip active" data-status="">All</button>
     <button class="filter-chip" data-status="ready">Ready</button>
     <button class="filter-chip" data-status="processing">Processing</button>
     <button class="filter-chip" data-status="error">Error</button>
     <button class="filter-chip" data-status="live">Live</button>
   </div>
   <select class="sort-select" id="sortSelect">
     <option value="newest">Newest</option>
     <option value="oldest">Oldest</option>
     <option value="name">Name A–Z</option>
     <option value="name-desc">Name Z–A</option>
     <option value="duration">Longest</option>
     <option value="size">Largest</option>
   </select>
   ```
   ```js
   // In state: statusFilter: '', sortBy: 'newest'
   function setupFilters() {
     document.querySelectorAll('.filter-chip').forEach(chip => {
       chip.addEventListener('click', () => {
         state.statusFilter = chip.dataset.status;
         document.querySelectorAll('.filter-chip').forEach(c =>
           c.classList.toggle('active', c.dataset.status === state.statusFilter)
         );
         renderAssets(state.assets);
       });
     });
     document.getElementById('sortSelect').addEventListener('change', e => {
       state.sortBy = e.target.value;
       renderAssets(state.assets);
     });
   }
   // In renderAssets() before rendering:
   let filtered = state.statusFilter
     ? assets.filter(a => {
         if (state.statusFilter === 'processing') return a.status === 'processing' || a.status === 'ingesting';
         return a.status === state.statusFilter;
       })
     : assets;
   if (term) filtered = filtered.filter(...);
   filtered = [...filtered].sort((a, b) => {
     switch (state.sortBy) {
       case 'oldest':    return new Date(a.created_at) - new Date(b.created_at);
       case 'name':      return (a.display_name||a.filename||'').localeCompare(b.display_name||b.filename||'');
       case 'name-desc': return (b.display_name||b.filename||'').localeCompare(a.display_name||a.filename||'');
       case 'duration':  return (b.duration_ms||0) - (a.duration_ms||0);
       case 'size':      return (b.file_size||0) - (a.file_size||0);
       default:          return new Date(b.created_at) - new Date(a.created_at);
     }
   });
   ```

   **Feature 2 — Inline rename (commit 9c83698):**
   ```html
   <!-- Asset name div gets data attribute -->
   <div class="asset-name" data-rename-id="${asset.id}" title="Double-click to rename">
     ${escHtml(asset.display_name || asset.filename)}
   </div>
   ```
   ```js
   function setupRenameListener(grid) {
     grid.addEventListener('dblclick', async e => {
       const nameEl = e.target.closest('.asset-name[data-rename-id]');
       if (!nameEl) return;
       e.stopPropagation();
       const assetId = nameEl.dataset.renameId;
       const current = nameEl.textContent;
       const input = document.createElement('input');
       input.type = 'text';
       input.value = current;
       input.className = 'asset-name-input';
       nameEl.style.display = 'none';
       nameEl.parentNode.insertBefore(input, nameEl.nextSibling);
       input.focus(); input.select();
       let saved = false;
       const save = async () => {
         if (saved) return;
         saved = true;
         const newName = input.value.trim();
         input.remove(); nameEl.style.display = '';
         if (!newName || newName === current) return;
         const r = await updateAsset(assetId, { display_name: newName });
         if (r.success) {
           nameEl.textContent = newName;
           const a = state.assets.find(x => x.id === assetId);
           if (a) a.display_name = newName;
           toast('Renamed', newName, 'success');
         } else toast('Rename failed', r.error, 'error');
       };
       const cancel = () => { if (saved) return; saved = true; input.remove(); nameEl.style.display = ''; };
       input.addEventListener('blur', save);
       input.addEventListener('keydown', e => {
         if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
         if (e.key === 'Escape') { e.preventDefault(); cancel(); }
       });
     });
   }
   ```
   - Card click handler also updated: `if (e.target.closest('.asset-name[data-rename-id]')) return;` to prevent preview opening on rename click

   **`services/web-ui/public/home.html`** (MODIFIED - commit f39d086)
   - **Why important**: Missing version strings on script tags
   - Changed `<script src="js/api.js">` → `<script src="js/api.js?v=6">`
   - Changed `<script src="js/topbar-strip.js">` → `<script src="js/topbar-strip.js?v=1">`
   - Home dashboard already dynamically loads live stats via `Promise.allSettled` on page load + 15s interval: asset count, project count, live recorder count, active job count

   **`services/web-ui/public/recorders.html`** (READ - NOT YET FIXED)
   - **Bug found**: In `renderProbeResult`, `d.error` is inserted directly into innerHTML without escaping:
   ```js
   // CURRENT (buggy):
   host.innerHTML = '<div ...>No signal detected</div><div ...>' + (d.error || 'Unknown error') + '</div>';
   // NEEDED FIX:
   host.innerHTML = '<div ...>No signal detected</div><div ...>' + esc(d.error || 'Unknown error') + '</div>';
   ```
   - The file is 43KB (very large), rest of the file is clean (esc() used consistently elsewhere)

   **`services/mam-api/src/routes/assets.js`** (READ - clean)
   - **Why important**: Backend assets route reviewed for filter/sort capabilities
   - Supports filter params: `project_id`, `bin_id`, `status`, `search`, `media_type`, `limit`, `offset`, `include_archived`
   - Always sorts `ORDER BY a.created_at DESC` (no sort param yet)
   - The new client-side filter chips in index.html work with the loaded data; the backend `status` filter could also be used but client-side is fine for typical project sizes

   **`services/web-ui/public/js/api.js`** (READ - clean, v=6)
   - Confirmed `formatDuration` is defined (fixes the capture.html concern)
   - Confirmed `retryAsset(assetId)` is present (added in prior session)
   - All API functions for assets, projects, bins, recorders, upload, users, groups, tokens, sequences are present

   **`services/web-ui/public/js/preview.js`** (READ - clean)
   - Asset preview modal, uses `esc()` and `textContent` throughout
   - Tags rendered with `esc(t)` wrapping: `(asset.tags||[]).map(t => \`${esc(t)}\`)`
   - Edit button URL construction uses port 47435 hardcoded (likely intentional for desktop app)

4. Errors and Fixes:
   - **api-tokens.html: JS injection in confirmRevoke onclick** — token names with single quotes broke the JS string in the onclick attribute because `esc()` doesn't escape `'`. Fixed by using `JSON.stringify(t.name)` wrapped in `esc()` so the name is encoded as a JSON string literal.
   - **player.html: XSS in renderTags** — tag values were inserted into innerHTML using template literals without escaping. Fixed by switching to pure DOM construction using `textContent`.
   - **capture.html, upload.html, player.html: stale api.js version** — These pages referenced `api.js?v=5` while the current version is 6. Fixed by updating the version query string.
   - **home.html: missing version strings** — `api.js` and `topbar-strip.js` were loaded without any version/cache-buster query params. Fixed by adding `?v=6` and `?v=1` respectively.
   - **recorders.html: unescaped d.error in innerHTML** (NOT YET FIXED) — `renderProbeResult` inserts the API error message directly into innerHTML. Needs `esc()` wrapper.

5. Problem Solving:
   - Systematically reviewed all HTML pages for the same class of JS injection bugs that was previously fixed in projects.html and users.html
   - Established that all `onclick` attributes using user-controlled strings must use `JSON.stringify(value)` + `esc()` pattern
   - Identified that `player.html` is a legacy page (uses old CSS class names: `app-container`, `header`, `header-nav`) that isn't actively linked from the new shell UI but still needed XSS fixes
   - Confirmed that `formatDuration` in `capture.html`'s `renderRecent` is safely provided by `api.js`
   - The backend `GET /assets` already supports a `status` filter param — the client-side filter chips don't need backend changes and are sufficient for typical broadcast project sizes
   - The inline rename feature updates the `state.assets` cache directly on success to prevent re-renders from reverting the displayed name

6. All User Messages:
   - (From prior session summary): "we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon - Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system."
   - "Tool loaded." (response to first ToolSearch for Forgejo tools)
   - "Tool loaded." (response to second ToolSearch in this session)
   - "CRITICAL: Respond with TEXT ONLY..." (current summary request)

7. Pending Tasks:
   - **Fix `recorders.html` renderProbeResult unescaped error** — Change `(d.error || 'Unknown error')` to `esc(d.error || 'Unknown error')` in the `renderProbeResult` function. This is the only remaining known security issue in the reviewed files.
   - **Review `js/auth-guard.js`** — Not yet reviewed
   - **Review `js/topbar-strip.js`** — Not yet reviewed
   - **Consider server-side sort param for GET /assets** — Currently always `ORDER BY created_at DESC`, client-side sort added to library but server-side would help large collections
   - **Continue reviewing any remaining unreviewed files** — New feature development continues

8. Current Work:
   Immediately before the summary request, three parallel commits had just succeeded:
   - `home.html` (commit f39d086) — version strings added
   - `index.html` (commit 9c83698) — inline rename feature added

   The next pending action that was queued but NOT yet executed was fixing `recorders.html` — specifically changing `(d.error || 'Unknown error')` to `esc(d.error || 'Unknown error')` in the `renderProbeResult` function. This file is 43KB so requires a full file resubmit. The full content of recorders.html was read earlier in the session (it was in the result from the `get_file` call that also retrieved `player.html`).

   The exact buggy line in recorders.html's `renderProbeResult`:
   ```js
   host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + (d.error || 'Unknown error') + '</div>';
   ```
   Must become:
   ```js
   host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + esc(d.error || 'Unknown error') + '</div>';
   ```

9. Optional Next Step:
   Fix the remaining security bug in `recorders.html` — escape `d.error` in `renderProbeResult` using the `esc()` function that is already defined in the file. The file requires a full content resubmit via `forgejo_update_file`. After that, check `js/auth-guard.js` and `js/topbar-strip.js` for any issues, then consider further new features such as server-side sort params for the assets API.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/node/.claude/projects/-home-node/e59aae98-44b9-4cb7-8cf4-3f02287a5c47.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


## claude.ai Context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


Let me continue from where I left off. The next task is to fix the `recorders.html` file to escape `d.error` in `renderProbeResult`. I need to read the current file content first, then make the fix.

Let me load the Forgejo tools and get the current content of recorders.html.


ToolSearch

Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_update_file


Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_update_file


Now let me get the current content of recorders.html to find the exact line to fix and then update it.


File: services/web-ui/public/recorders.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Recorders — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css?v=3">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Recorders

        <button class="btn btn-primary btn-sm" id="newRecorderBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New recorder
        </button>

          <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="9" width="26" height="22" rx="2"/><path d="M28 16l10-5v18l-10-5"/><circle cx="15" cy="20" r="4"/></svg>

No recorders yet

Create a recorder to ingest live streams via SRT, RTMP, or SDI.

          <button class="btn btn-primary btn-sm" onclick="openPanel()">New recorder</button>

    New recorder
    <button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Recorder name
      <input type="text" id="recName" placeholder="e.g. Studio A SRT">

      Source type

        <button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
        <button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
        <button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>

Recording settings

        Codec
        <select id="recCodec">
          <option value="prores_hq">ProRes HQ</option>
          <option value="prores_422">ProRes 422</option>
          <option value="prores_lt">ProRes LT</option>
          <option value="h264">H.264</option>
          <option value="dnxhd">DNxHD</option>
        </select>

        Resolution
        <select id="recResolution">
          <option value="native">Native (source)</option>
          <option value="1920x1080">1920×1080</option>
          <option value="1280x720">1280×720</option>
          <option value="3840x2160">3840×2160</option>
        </select>

        <input type="checkbox" id="proxyToggle">

        Generate proxy on stop

        Proxy codec
        <select id="proxyCodec">
          <option value="h264">H.264</option>
          <option value="h265">H.265</option>
        </select>

        Proxy bitrate
        <select id="proxyBitrate">
          <option value="2000k">2 Mbps</option>
          <option value="4000k">4 Mbps</option>
          <option value="8000k">8 Mbps</option>
        </select>

Destination

        Project
        <select id="recProject">
          <option value="">None (manual assignment)</option>
        </select>

        Bin
        <select id="recBin">
          <option value="">Project root</option>
        </select>

    <button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
    <button class="btn btn-secondary" id="probeBtn">Probe source</button>
    <button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>


I can see the bug in the `renderProbeResult` function. The line:

```js
host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + (d.error || 'Unknown error') + '</div>';
```

Needs to be changed to:

```js
host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + esc(d.error || 'Unknown error') + '</div>';

```

Now I need to check the auth-guard.js and topbar-strip.js files as well. I'll apply the fix to recorders.html by preparing the corrected file content with the escaped error message, then fetch those other files to review them.

```
I need to apply the fix to recorders.html by wrapping the error message with the `esc()` function to prevent XSS vulnerabilities. While I prepare that update, I'll also fetch the auth-guard.js and topbar-strip.js files in parallel to check their status.


I can see the bug. Let me fix `recorders.html` and simultaneously review the two remaining JS files.


File: services/web-ui/public/js/auth-guard.js

/**
 * auth-guard.js
 * Included on every protected page.
 *
 * - If /api/v1/auth/me returns 401 → redirect to login.html immediately.
 *   (When AUTH_ENABLED=false the endpoint returns a synthetic guest user,
 *   so the redirect only fires in production auth-enabled mode.)
 * - On success, populate the sidebar user widget and wire up the logout button.
 */
(async () => {
  try {
    const r = await fetch('/api/v1/auth/me', { credentials: 'include' });
    if (r.status === 401) {
      location.replace('login.html');
      return;
    }
    if (r.ok) {
      const u = await r.json();
      const name = u.display_name || u.username || 'User';
      const userNameEl   = document.getElementById('userName');
      const userAvatarEl = document.getElementById('userAvatar');
      const userRoleEl   = document.getElementById('userRole');
      if (userNameEl)   userNameEl.textContent   = name;
      if (userAvatarEl) userAvatarEl.textContent = name[0].toUpperCase();
      if (userRoleEl)   userRoleEl.textContent   = u.role || '';
    }
  } catch (_) {
    // Network error — don't redirect; the user may be on a dev build without auth.
  }
  const logoutBtn = document.getElementById('logoutBtn');
  if (logoutBtn) {
    logoutBtn.onclick = async () => {
      try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
      location.href = 'login.html';
    };
  }
})();


File: services/web-ui/public/js/topbar-strip.js

// Operator status strip mounted at the top of every .main pane.
(function () {
  function mount() {
    const main = document.querySelector('.main');
    if (!main || main.querySelector('.topbar-strip')) return;
    const strip = document.createElement('div');
    strip.className = 'topbar-strip';
    strip.innerHTML =
      '' +
      'Z-AMPP' +
      '00:00:00' +
      '' +
      'Page' +
      '' + pageName() + '' +
      '' +
      'API' +
      '--';
    main.insertBefore(strip, main.firstChild);
    tick(); setInterval(tick, 1000);
    ping(); setInterval(ping, 10000);
  }
  function pageName() {
    const p = location.pathname.replace(/\.html$/, '').replace(/^\//, '');
    if (!p || p === 'index') return 'Library';
    return p.charAt(0).toUpperCase() + p.slice(1);
  }
  function tick() {
    const n = new Date();
    const hh = String(n.getHours()).padStart(2, '0');
    const mm = String(n.getMinutes()).padStart(2, '0');
    const ss = String(n.getSeconds()).padStart(2, '0');
    const el = document.getElementById('tsNow');
    if (el) el.textContent = hh + ':' + mm + ':' + ss;
  }
  async function ping() {
    const dot = document.getElementById('tsDot');
    const apiEl = document.getElementById('tsApi');
    const t0 = performance.now();
    try {
      const r = await fetch('/api/v1/projects', { credentials: 'include', signal: AbortSignal.timeout(4000) });
      const ms = Math.round(performance.now() - t0);
      if (r.ok) {
        if (dot) { dot.style.background = 'var(--signal-good)'; dot.style.boxShadow = '0 0 8px var(--signal-good)'; }
        if (apiEl) apiEl.textContent = ms + ' ms';
      } else {
        if (dot) { dot.style.background = 'var(--signal-warn)'; dot.style.boxShadow = '0 0 8px var(--signal-warn)'; }
        if (apiEl) apiEl.textContent = 'HTTP ' + r.status;
      }
    } catch (e) {
      if (dot) { dot.style.background = 'var(--signal-bad)'; dot.style.boxShadow = '0 0 8px var(--signal-bad)'; }
      if (apiEl) apiEl.textContent = 'offline';
    }
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount);
  else mount();
})();


fix(recorders): escape d.error in renderProbeResult to prevent XSS

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Recorders — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css?v=3">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Recorders

        <button class="btn btn-primary btn-sm" id="newRecorderBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New recorder
        </button>

          <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="9" width="26" height="22" rx="2"/><path d="M28 16l10-5v18l-10-5"/><circle cx="15" cy="20" r="4"/></svg>

No recorders yet

Create a recorder to ingest live streams via SRT, RTMP, or SDI.

          <button class="btn btn-primary btn-sm" onclick="openPanel()">New recorder</button>

    New recorder
    <button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Recorder name
      <input type="text" id="recName" placeholder="e.g. Studio A SRT">

      Source type

        <button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
        <button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
        <button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>

Recording settings

        Codec
        <select id="recCodec">
          <option value="prores_hq">ProRes HQ</option>
          <option value="prores_422">ProRes 422</option>
          <option value="prores_lt">ProRes LT</option>
          <option value="h264">H.264</option>
          <option value="dnxhd">DNxHD</option>
        </select>

        Resolution
        <select id="recResolution">
          <option value="native">Native (source)</option>
          <option value="1920x1080">1920×1080</option>
          <option value="1280x720">1280×720</option>
          <option value="3840x2160">3840×2160</option>
        </select>

        <input type="checkbox" id="proxyToggle">

        Generate proxy on stop

        Proxy codec
        <select id="proxyCodec">
          <option value="h264">H.264</option>
          <option value="h265">H.265</option>
        </select>

        Proxy bitrate
        <select id="proxyBitrate">
          <option value="2000k">2 Mbps</option>
          <option value="4000k">4 Mbps</option>
          <option value="8000k">8 Mbps</option>
        </select>

Destination

        Project
        <select id="recProject">
          <option value="">None (manual assignment)</option>
        </select>

        Bin
        <select id="recBin">
          <option value="">Project root</option>
        </select>

    <button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
    <button class="btn btn-secondary" id="probeBtn">Probe source</button>
    <button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>


OK | commit:76b0a5e | Updated services/web-ui/public/recorders.html


The recorders.html fix was committed successfully (commit 76b0a5e).

Now let me review the two JS files:

1. **auth-guard.js** - This is clean. It:
   - Uses `textContent` for all user data (name, avatar initial, role) — no innerHTML with user data
   - No injection vulnerabilities
   - Straightforward auth check + logout button wiring

2. **topbar-strip.js** - This is also clean. It:
   - Uses `pageName()` for page display, which is derived from `location.pathname` — while this uses innerHTML, `location.pathname` doesn't contain HTML-dangerous characters in practice (URLs can't contain `<`, `>`, `&` in the path segment without percent-encoding, so this is low risk)
   - API latency comes from `Math.round(performance.now() - t0)` — safe number
   - HTTP status comes from `r.status` — safe number
   - The `'offline'` string is a literal

Let me examine the `pageName()` function more closely to verify it's handling the pathname safely...

The function extracts and formats the pathname, then inserts it into innerHTML. While browsers URL-encode angle brackets in the path, a crafted URL could potentially bypass this in edge cases. The safer approach would be to use `textContent` instead of innerHTML, but I'm realizing the function doesn't have access to the `esc()` utility from the page-level script anyway, so this is a potential vulnerability that needs addressing. I realize the risk is actually pretty low since browsers percent-encode special characters in the pathname, and anyone with the ability to manipulate it via `history.pushState` would already have code execution. Still, I should apply consistent safety practices across the codebase. Let me scan through the remaining files to see if there's anything else I've missed. The pageName() issue in topbar-strip.js is low risk since browsers handle URL encoding, but I'll fix it anyway by adding a simple escape function inside the IIFE or setting textContent after building the strip. Let me pull up the directory listing to see what HTML files need reviewing and plan the next batch of work.


`recorders.html` fixed (commit 76b0a5e). Both JS files are clean — `auth-guard.js` uses `textContent` throughout, `topbar-strip.js` uses only literal/numeric values in innerHTML except `pageName()` which derives from `location.pathname`. Path segments are URL-encoded by browsers, but I'll tighten it up with an inline `esc()` to be consistent.

Let me also see what HTML files remain unreviewed:


📁 css
📁 img
📁 js
📄 api-tokens.html (15120B)
📄 capture.html (20138B)
📄 edit.html (16507B)
📄 editor.html (36884B)
📄 favicon.ico (4492B)
📄 home.html (23418B)
📄 index.html (42085B)
📄 jobs.html (31755B)
📄 login.html (9501B)
📄 player.html (18771B)
📄 projects.html (24188B)
📄 recorders.html (43111B)
📄 tokens.html (36314B)
📄 upload.html (19437B)
📄 users.html (24983B)


fix(topbar-strip): escape pageName() output before innerHTML insertion

// Operator status strip mounted at the top of every .main pane.
(function () {
  function esc(s) {
    if (!s) return '';
    return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
  }
  function mount() {
    const main = document.querySelector('.main');
    if (!main || main.querySelector('.topbar-strip')) return;
    const strip = document.createElement('div');
    strip.className = 'topbar-strip';
    strip.innerHTML =
      '' +
      'Z-AMPP' +
      '00:00:00' +
      '' +
      'Page' +
      '' + esc(pageName()) + '' +
      '' +
      'API' +
      '--';
    main.insertBefore(strip, main.firstChild);
    tick(); setInterval(tick, 1000);
    ping(); setInterval(ping, 10000);
  }
  function pageName() {
    const p = location.pathname.replace(/\.html$/, '').replace(/^\//, '');
    if (!p || p === 'index') return 'Library';
    return p.charAt(0).toUpperCase() + p.slice(1);
  }
  function tick() {
    const n = new Date();
    const hh = String(n.getHours()).padStart(2, '0');
    const mm = String(n.getMinutes()).padStart(2, '0');
    const ss = String(n.getSeconds()).padStart(2, '0');
    const el = document.getElementById('tsNow');
    if (el) el.textContent = hh + ':' + mm + ':' + ss;
  }
  async function ping() {
    const dot = document.getElementById('tsDot');
    const apiEl = document.getElementById('tsApi');
    const t0 = performance.now();
    try {
      const r = await fetch('/api/v1/projects', { credentials: 'include', signal: AbortSignal.timeout(4000) });
      const ms = Math.round(performance.now() - t0);
      if (r.ok) {
        if (dot) { dot.style.background = 'var(--signal-good)'; dot.style.boxShadow = '0 0 8px var(--signal-good)'; }
        if (apiEl) apiEl.textContent = ms + ' ms';
      } else {
        if (dot) { dot.style.background = 'var(--signal-warn)'; dot.style.boxShadow = '0 0 8px var(--signal-warn)'; }
        if (apiEl) apiEl.textContent = 'HTTP ' + r.status;
      }
    } catch (e) {
      if (dot) { dot.style.background = 'var(--signal-bad)'; dot.style.boxShadow = '0 0 8px var(--signal-bad)'; }
      if (apiEl) apiEl.textContent = 'offline';
    }
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount);
  else mount();
})();


OK | commit:36f1658 | Updated services/web-ui/public/js/topbar-strip.js


Good, topbar-strip.js is now fixed (commit 36f1658).

Now I need to review the remaining unreviewed pages:
- `edit.html` - not reviewed yet (new in this list, 16507B)
- `editor.html` - not reviewed yet (36884B)
- `jobs.html` - not reviewed yet (31755B)
- `login.html` - not reviewed yet (9501B)
- `projects.html` - mentioned as fixed in prior sessions
- `users.html` - mentioned as fixed in prior sessions
- `tokens.html` - this is api-tokens.html which was fixed (but wait, there's also `tokens.html` at 36314B — that's different from `api-tokens.html` at 15120B!)

- `tokens.html` (36314B) is actually a separate file from the already-fixed `api-tokens.html`, so it needs review too.

I'll fetch the security-critical files in parallel: login.html, edit.html, jobs.html, and tokens.html, then handle the larger editor.html separately if needed.


`topbar-strip.js` fixed (commit 36f1658). I can see there are still several unreviewed files: `edit.html`, `editor.html`, `jobs.html`, `login.html`, and importantly `tokens.html` (36KB — different from the already-fixed `api-tokens.html`). Fetching them all in parallel now.


File: services/web-ui/public/edit.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Editor — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Editor
        /
        Phase A · single-track

        <button class="btn btn-ghost btn-sm" onclick="saveEDL()">Save draft</button>
        <button class="btn btn-primary btn-sm" onclick="exportEDL()">Export</button>

          Library

<input type="text" id="assetSearch" placeholder="Search assets…" />

Loading…

              <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="36" height="36" style="opacity:0.4"><polygon points="11,8 24,16 11,24"/></svg>

Drag a clip onto the timeline below, then click it to preview.

            <video id="previewVideo" style="display:none" playsinline></video>

            <button class="edit-transport-btn" id="btnPlay" title="Play / pause (space)" disabled>
              <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><polygon points="4,3 13,8 4,13"/></svg>
            </button>

<input type="range" id="scrubber" min="0" max="100" value="0" step="0.01" disabled>

            --:--.-- / --:--.--
            <button class="edit-marker-btn in"  id="btnSetIn"  title="Set In (I)"  disabled>I  IN</button>
            <button class="edit-marker-btn out" id="btnSetOut" title="Set Out (O)" disabled>O  OUT</button>
            <button class="edit-marker-btn" id="btnSplit" title="Split at playhead (B)" disabled style="color:oklch(70% 0.18 80)">B  SPLIT</button>

            Timeline · Track 1
            0 clips · 00:00.00

Drag clips here from the Library

Select a clip on the timeline to inspect.


File: services/web-ui/public/jobs.html

<!DOCTYPE html>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Jobs — Wild Dragon MAM</title>
<link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Jobs

        <button class="btn btn-ghost btn-sm" id="btn-clear-done" onclick="clearCompleted()">
          Clear completed
        </button>

—

Total

—

Active

—

Completed

—

Failed

          <button class="tab-btn active" data-filter="all" onclick="setFilter('all', this)">
            All 0
          </button>
          <button class="tab-btn" data-filter="active" onclick="setFilter('active', this)">
            Active 0
          </button>
          <button class="tab-btn" data-filter="completed" onclick="setFilter('completed', this)">
            Completed 0
          </button>
          <button class="tab-btn" data-filter="failed" onclick="setFilter('failed', this)">
            Failed 0
          </button>

        <select class="form-select type-select" id="type-filter" onchange="loadJobs()">
          <option value="">All types</option>
          <option value="transcode">Transcode</option>
          <option value="proxy">Proxy</option>
          <option value="thumbnail">Thumbnail</option>
          <option value="conform">Conform</option>
          <option value="ingest">Ingest</option>
        </select>

          Live

    Job Detail
    <button class="btn btn-ghost btn-sm" onclick="closeDetail()" aria-label="Close" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>


File: services/web-ui/public/login.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Sign in - Z-AMPP</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">

    AMPP Safe

Broadcast-safe media,
end to end.

Z-AMPP is the Wild Dragon media-asset hub. Ingest live SRT & RTMP feeds, upload masters, and hand proxies to your editors in seconds.

Z

Z-AMPP

Media Asset Management

Sign in

Enter your credentials to continue.

    <form id="login-form" autocomplete="on">

        Username
        <input id="username" name="username" type="text" autocomplete="username" required placeholder="username">

        Password
        <input id="password" name="password" type="password" autocomplete="current-password" required placeholder="••••••••">

      <button type="submit" class="btn" id="login-btn">Sign in</button>
    </form>

First time? Create admin account

Create admin

No accounts exist yet - create the first admin.

    <form id="setup-form" autocomplete="off">

Username<input id="su-username" name="username" type="text" required placeholder="admin">

Password (min 8 chars)<input id="su-password" name="password" type="password" required minlength="8" placeholder="••••••••">

Display name (optional)<input id="su-display" name="display_name" type="text" placeholder="Admin">

      <button type="submit" class="btn" id="setup-btn">Create account</button>
    </form>

Back to login


File: services/web-ui/public/tokens.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Token Pricing — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Token Pricing
        /
        Enterprise Compute Compliance Engine v4.7

        <button class="btn btn-ghost btn-sm" onclick="alert('Your account executive will be in touch.\\n\\nEstimated response time: 6–12 business days.')">Talk to sales</button>

        Z-AMPP Pricing

Per-seat · Per-stream · Per-month
Per token.

Welcome to the future of broadcast media operations. Tokens are fungible compute credits that flexibly meter every action across the Platform™. Move faster. Pay precisely. Forecast nothing.

Starter

$499 / mo

100,000 tokens · $4.99 / 1k

1 concurrent recorder

SD ingest (480p · 1.2× multiplier)

Standard support · email · 96h SLA

No HD codec access

No ProRes write

          <button class="tok-tier-cta" onclick="addToCart('Starter')">Get started</button>

          Most flexible

Professional

$2,499 / mo

600,000 tokens · $4.17 / 1k

4 concurrent recorders

HD ingest (1080p · 3.2× multiplier)

ProRes HQ write (2.4× multiplier)

Priority queue · 24h SLA

4K surcharge applies

          <button class="tok-tier-cta" onclick="addToCart('Professional')">Provision tier</button>

Broadcast

$8,999 / mo

2,400,000 tokens · $3.75 / 1k

12 concurrent recorders

4K ingest (5.8× multiplier)

ProRes 4444 write (4.0× multiplier)

Named CSM · phone · 4h SLA

Token rollover (90 days, fees apply)

          <button class="tok-tier-cta" onclick="addToCart('Broadcast')">Engage account team</button>

Enterprise

Contact us

Custom token allocation

Unlimited* concurrent recorders

8K / IMF / DCP write tiers

Dedicated solutions architect

Quarterly token true-up audits

Implementation fee not included

          <button class="tok-tier-cta" onclick="addToCart('Enterprise')">Request quotation</button>

        Per-Service Metering
        All rates exclusive of TVM · effective Q3 FY26

          Service
          Meter
          Base rate
          Multiplier

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>

LibraryAsset browse, search, thumbnail render

Per asset · per hour

0.012 tokens

1.00×

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>

IngestUpload + transcode to managed proxy

Per GB · per pass

14.4 tokens / GB

2.4× during business hours

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>

Recorder · SRTCaller-mode network ingest, includes HLS preview*

Per minute · per recorder

4.8 tokens / min

+22% Reliability Adjustment

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>

Recorder · RTMPGeneric ingest tier · legacy codec compatibility

Per minute · per recorder

3.6 tokens / min

1.00×

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>

Capture · SDIDeckLink baseband ingest · 12G-SDI add-on available

Per minute · per SDI channel

9.2 tokens / min

1.8× premium baseband multiplier

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="13" cy="3" r="1"/></svg>

Live HLS PreviewReal-time delivery acceleration (RTDA™)

Per active viewer · per second

0.0008 tokens

3.2× CDN egress premium

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8h6M8 5v6"/></svg>

ProRes HQ WriteMastering-grade codec licensing

Per minute of media

6.4 tokens / min

2.4× codec entitlement fee

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>

Editor renderServer-side concat / trim · brand-aligned codec ladder

Per minute of output

11.8 tokens / min

+18% Real-Time Render Surcharge

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>

Background JobsProxy gen, thumbnails, AMPP folder sync

Per job · per CPU-second

0.45 tokens

0.85× off-peak discount

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3h12v10H2z"/><path d="M5 6h6M5 9h4"/></svg>

Premiere Pro ConnectorCEP bridge · per-NLE seat compatibility license

Per workstation · per month

22,000 tokens

+ $99 NLE Compatibility Levy

<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/></svg>

API callGET /api/v1/* · includes 200-byte response budget

Per request

0.0011 tokens

1.00× (overage 3.4×)

        Current Token Burn
        Last 14 days · nightly true-up · TVM applied

MTD burn—+0%

Forecast EOM—over plan

TVM (live)—stable

Peak draw—Wed

          <svg class="tok-chart-svg" id="burnChart" viewBox="0 0 800 220" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>

            Ingest
            Recorders
            Render
            Overage

Monthly token estimator

Honest forecasts since 2019. Actual usage may vary by up to 340%.

Ingest GB/mo<input id="iIngest" type="number" value="800" min="0">

SRT recorder hours<input id="iSrt" type="number" value="120" min="0">

SDI capture hours<input id="iSdi" type="number" value="40" min="0">

Premiere seats<input id="iSeats" type="number" value="3" min="0">

Editor render min/mo<input id="iRender" type="number" value="240" min="0">

            Estimated monthly tokens
            —

            At Professional tier
            —

Includes 2.4× business-hours Ingest multiplier. Excludes overage, peak-hour surcharge, codec entitlement, and the Token Velocity Modifier (TVM™), which fluctuates between 0.8× and 4.2× without notice.

Disclosures. All rates quoted in Z-Tokens®, a non-transferable digital unit of account valid only within the Platform™. One (1) token is equivalent to 1.0 token at time of redemption, subject to the Token Velocity Modifier (TVM™), which is recalculated nightly and applied retroactively where contractually permitted. Tokens expire after 30 days unless rolled over with the Token Continuity Add-On (TCA, sold separately).

Multipliers. "Reliability Adjustment", "Real-Time Render Surcharge", and "Premium Baseband Multiplier" are not surcharges; they are positive entitlements that grant continued access to services for which you have already paid. Refusal to pay constitutes voluntary entitlement waiver.

Token Compatibility Levy. A 14% sustainability levy is automatically applied to all token consumption in support of the Platform's commitment to operational excellence. The levy is non-refundable, non-itemized, and not represented above.

Forward-looking statements. Anything resembling a price on this page is illustrative and is not, has not been, and will never be, a price. Pricing is determined exclusively by your assigned Customer Success Outcome Architect during quarterly value-realization workshops.

Z-AMPP is the broadcast asset management platform you actually own. No token has ever been minted, charged, or considered. This page exists for purely educational purposes. Any resemblance to a real metered-compute pricing model is entirely intentional and deeply affectionate.


Let me analyze each file:

**edit.html:**
- Has `<script src="js/api.js">` — missing version string (should be `?v=6`)
- Has `<script src="js/topbar-strip.js">` — missing version string (should be `?v=1`)
- No obvious XSS issues since it delegates rendering to `js/edit.js?v=2`
- Need to bump api.js and topbar-strip.js

**jobs.html:**
- Uses `escHtml()` function defined inline — clean
- All user data is properly escaped with `escHtml()`

- The job type and asset name are safely set via `textContent`, and `renderRow()` uses `innerHTML` but wraps all user content in `escHtml()` — both approaches are secure
- I notice the script tags have inconsistent versioning — `topbar-strip.js?v=1` is present but `api.js` is missing its version string entirely

, and `auth-guard.js` has no version at all
- Actually, jobs.html doesn't load `api.js` at all since it defines its own `api()` helper function inline, so that's not a concern
- The CSS file `common.css` also lacks versioning, though CSS is less critical for security than JavaScript

Now checking the other pages: jobs.html's data handling looks secure — everything's properly escaped with `escHtml()`, the detail panel uses `textContent` for titles and `escHtml()` for content. Login.html is clean too — it uses `textContent` for flash messages rather than `innerHTML`, so even error messages from the network or server are safe. Tokens.html is the satirical pricing page, and it's building SVG dynamically by injecting computed numbers directly into the DOM.

The SVG generation in tokens.html uses `innerHTML` but only with numeric values from `Math.round()` and date strings from `toLocaleDateString()` — no user input involved, so no XSS risk there. The rest of the page uses `textContent` for stats and only numeric values in the calculator.

The real issue I'm spotting is that both edit.html and tokens.html are missing version query strings on their script includes for `js/api.js` and `js/topbar-strip.js`. I need to add version parameters to those script tags to ensure cache busting.

Now looking at jobs.html, I notice `js/auth-guard.js` also lacks a version string. I'm trying to figure out what version number to assign — topbar-strip.js got `?v=1`, but I'm not certain what version auth-guard.js should have since it appears across multiple pages without versioning. Let me check if there's a pattern in how these files have been versioned elsewhere.

Actually, I'm realizing auth-guard.js has never been versioned on any page, which suggests it's intentionally left unversioned since it's not cache-sensitive. The real issues I need to focus on are edit.html and tokens.html, which are both missing version strings on api.js and topbar-strip.js. For jobs.html, topbar-strip.js already has `?v=1`, so that's consistent with the pattern. I should also check editor.html, projects.html, and users.html to see if they have the same versioning gaps.

Before diving into more fixes, I'm thinking about what new features would be most valuable to add—maybe a retry button for failed jobs, bulk operations in the library with checkboxes, or server-side sorting for the assets API.


**Analysis complete:**
- **edit.html**: Missing `?v=6` on `api.js` and `?v=1` on `topbar-strip.js`
- **jobs.html**: Clean — uses own `escHtml()`, properly escapes all data
- **login.html**: Clean — uses `textContent` for all error messages
- **tokens.html**: Satirical pricing page, all chart data is computed numbers only. Missing `?v=6` on `api.js` and `?v=1` on `topbar-strip.js`

Fixing version strings on both and fetching `editor.html` simultaneously: