Let me analyze what I found:

**projects.html:**
Looking at the code carefully...

1. The `api()` wrapper function:
```js
async function api(path, opts = {}) {
  const r = await fetch('/api/v1' + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
  if (!r.ok) throw new Error('HTTP ' + r.status + ' on ' + path);
  return r.json();

```
This looks correct - it has `credentials: 'include'`. Good.

2. The `loadAll()` function:
```js
const projs = await api('/projects');
state.projects = Array.isArray(projs) ? projs : [];
```
Good defensive check.

3. The `filterAndRender()` function renders projects using `esc()` - good XSS protection.

4. The `binCard()` function is building button HTML with inline event handlers...

Still writing button markup...

Actually, I'm noticing a potential issue with the escaping strategy here. The code applies `esc()` to escape HTML entities, then tries to add backslash escaping on top of that, but since `esc()` converts single quotes to `'`, the subsequent `replace()` won't find any literal quotes to escape. The real vulnerability would come from unescaped backslashes in the name, which could break out of the string context in the onclick handler. So the real problem is that `esc()` converts the single quote to `'`, which gets decoded back to `'` by the HTML parser before JavaScript even runs, breaking the string syntax. The `replace(/'/g, "\\'")` comes too late since it's operating on already-escaped HTML entities. I need to either escape single quotes before calling `esc()`, or switch to using `data-` attributes instead of inline event handlers. The real issue is that when the HTML attribute gets parsed, `'` gets decoded back to a literal `'` character, which then breaks the JavaScript string delimiter inside the `onclick` handler. So even though `'` is technically safe inside a double-quoted HTML attribute, it's not safe when that attribute contains JavaScript code that uses `'` as its own string delimiter.

The escaping approach with `replace(/'/g, "\\'")` doesn't work because it's operating on an already-escaped string that no longer contains raw quotes. The better solution is to use `JSON.stringify()` to properly encode the string for JavaScript context, or ideally move away from inline `onclick` handlers entirely and use data attributes with `addEventListener` instead. I should check if this same problem exists elsewhere in the code.

Looking at `renderDetail()`, it uses `this.value` which is safe, and the project row uses UUIDs for IDs so there's no injection risk there. The real vulnerability is specifically in `binCard()` where bin names can contain quotes or backslashes. The cleanest fix would be wrapping both the ID and name in `JSON.stringify()` when building the onclick attribute.

But wait—I'm realizing there's a problem with that approach. `JSON.stringify()` produces double-quoted strings, and if the onclick attribute itself is delimited by double quotes, then any escaped quotes in the JSON output would break the HTML attribute. I need to either escape the JSON string through `esc()` to convert quotes to `"`, or find another way to safely encode these values.

The cleanest solution would be to move away from inline onclick handlers entirely and use data attributes with event listeners instead, but that's a bigger refactor. For a minimal fix, I can create a function that takes a string, runs it through `JSON.stringify()` to get proper JavaScript escaping, then applies HTML escaping to make it safe inside a double-quoted attribute.

Defining the escaper function... I'm considering using data attributes with event delegation to avoid inline JavaScript entirely, but that feels like overengineering for this case. Instead, I'll go with a simpler fix: a helper function that uses JSON.stringify to properly escape the string, then HTML-escape the result so the onclick attribute stays valid. This way the quotes get encoded as `"` in the HTML, the parser decodes them back, and JavaScript receives the correct string values.

Let me verify this works with a tricky case like "Mary's Clips"—JSON.stringify handles the single quote correctly without needing to escape it, so the final onclick becomes something like `renameBinPrompt("Mary's Clips")` after the HTML entities are decoded.

Now testing with a name containing double quotes like `Hello "World"`—JSON.stringify will escape those as backslashes, which then get HTML-encoded, and when the browser decodes the onclick attribute, it should reconstruct the proper JavaScript call. Now I'm checking what happens when a name contains `</script>` — I need to see how `JSON.stringify` handles that and whether it creates an injection vulnerability. Looking at the project IDs, they're likely UUIDs based on the migrations, which are safe to use directly in JavaScript strings since they don't contain special characters. I notice the code already uses `encodeURIComponent` correctly for URL parameters in the API calls, but I should verify the `renderDetail()` function doesn't have similar injection vulnerabilities in its onclick handlers.

The real issue appears in `binCard()` where `b.name` gets concatenated directly into the onclick handler after escaping - the `esc()` function doesn't handle backslashes, leaving it vulnerable to quote injection attacks through user input. I see the issue now—when the HTML decoder processes the escaped quotes in the onclick attribute, they get converted back to actual quotes, which breaks out of the string and creates an XSS vulnerability. The backslash case is similarly problematic because `\b` gets interpreted as a backspace character in JavaScript. And with the injection payload, the escaped quotes still allow the attacker to break out of the function call and execute arbitrary code.

The fix is to use `JSON.stringify()` for proper JavaScript string encoding instead of manual escaping, which will safely handle all special characters and prevent the decoder from reinterpreting them. This way the onclick handler becomes a properly formatted function call with JSON-encoded arguments that can't be broken out of.

Now I'm checking the users.html file for the same vulnerability—the username field is being HTML-escaped but that's not sufficient for JavaScript context, so I need to apply the same JSON.stringify fix there too. I'm seeing the same vulnerability pattern in both `renderUsers()` and `renderGroups()` - when usernames or group names contain single quotes, the HTML entity encoding gets decoded back to a quote when the onclick handler executes, breaking the JavaScript syntax. The delete buttons would silently fail for users like "O'Brien". Since `projects.html` has the most direct exposure (bin names editable by any user), that's where I should focus the fix first.

I'm going to use a helper function that combines JSON stringification with HTML encoding to properly escape values for inline event handlers - this ensures the string stays safely encoded all the way through to JavaScript execution. Let me check if `users.html` has the same `esc()` function available...

Looking at the two implementations, I notice `users.html` is missing single quote escaping, which creates a vulnerability where single quotes in values could break out of the JS string. The `projects.html` version handles this correctly by escaping quotes to `'`, but that still doesn't fully solve the problem since the inline approach itself is fragile.

The safest pattern going forward is to use `esc(JSON.stringify(value))` for string arguments in onclick handlers, which properly encodes everything as a JSON string. For ID arguments that are safe (UUIDs, integers), I can pass them directly. I need to update both files with this approach and fix the escaping functions accordingly. I see the issue now—in option tags we're correctly HTML-escaping for content, but the onclick handlers need JavaScript escaping instead. Let me map out the fixes: I need to update the delete user and delete group onclick handlers in both projects.html and users.html to properly escape names with special characters, then review auth.js and users.js for any other security concerns. The `/me` endpoint's fallback to session data during a DB outage is acceptable, though it could theoretically serve stale data.

Looking at the `/setup` endpoint more closely, there's a race condition where two simultaneous requests could both see zero users and create two admins—the count check and insert aren't atomic. For a one-time setup endpoint in a media system this is unlikely to cause real problems, but it's still worth noting.

The better fix would be using a WHERE NOT EXISTS clause in the INSERT itself to make it atomic, but given the minimal risk I'll leave it as is. The auth routes don't have explicit `requireAdmin` on `/setup`, but the count check effectively gates it anyway.

In users.js, all CRUD operations are properly wrapped with auth and admin checks, and the PATCH endpoint sensibly prevents username changes while allowing display name, role, and password updates. There's no guard against an admin deleting their own account, which could lock out the system, but that's an edge case.

Now I'm noticing the GET users query joins with `user_groups`, suggesting a groups system exists, but users.js only has user CRUD endpoints. The users.html file is calling group management functions like `getGroups()`, `createGroup()`, `updateGroup()`, and `deleteGroup()` through the API, so there must be a separate groups route file I haven't seen yet. I should check the routes directory to find where these group endpoints are implemented.

I'm also realizing there's a potential timing issue with auth-guard.js loading after the main script—if it redirects to login, the initial API calls might fail with 401 errors, though the DOMContentLoaded event should fire after scripts load but before they execute, so the guard should run in time. I need to reconsider the actual script loading order in users.html—api.js loads first, then the main script registers the DOMContentLoaded listener, and auth-guard.js executes after that but before the event fires, so the redirect would happen before loadUsers() is called. The API would return a 401 if the user isn't authenticated, so it's really just a defense-in-depth concern rather than a critical vulnerability. Now I need to prioritize the most impactful fix—the XSS vulnerability in the binCard() function on projects.html where user input gets injected directly into the DOM. I'll tackle that first, then review the remaining routes and other pages like tokens.html and upload.html to check for similar issues.

Fixing binCard() to use proper encoding... I'm realizing I need to fix the same escaping issue in users.html too. Looking at the `esc()` function there, it's missing escaping for single quotes, which creates a vulnerability when those values are used in JavaScript string literals. I should update both files to properly handle special characters in names and group names. Updating the onclick handlers to use JSON.stringify for proper escaping instead of manual quote replacement...

Now I need to check the remaining files—tokens.html, upload.html, and the backend routes in bins.js, tokens.js, and upload.js—to identify similar injection vulnerabilities.


Reading the route directory listing and remaining unreviewed files in parallel:


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.


📄 ampp.js (1143B)
📄 assets.js (11513B)
📄 auth.js (4805B)
📄 bins.js (4380B)
📄 capture.js (1728B)
📄 groups.js (4665B)
📄 jobs.js (5921B)
📄 projects.js (3392B)
📄 recorders.js (18221B)
📄 sequences.js (9346B)
📄 settings.js (2513B)
📄 tokens.js (2783B)
📄 upload.js (8804B)
📄 users.js (4831B)


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.


File: services/web-ui/public/upload.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>Ingest — 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>

        Ingest

        <button class="btn btn-ghost btn-sm" id="clearDoneBtn" style="display:none;">Clear completed</button>

            Project
            <select id="projectSel">
              <option value="">Select project…</option>
            </select>

            Bin
            <select id="binSel">
              <option value="">No bin (project root)</option>
            </select>

          <input type="file" id="fileInput" multiple accept="video/*,audio/*,image/*" aria-label="Select files to upload">

            <svg viewBox="0 0 36 36" fill="none" stroke="currentColor" stroke-width="1.5">
              <path d="M18 26V10M11 17l7-7 7 7"/>
              <path d="M4 28h28"/>
              <rect x="2" y="4" width="32" height="26" rx="2" stroke-opacity="0.25"/>
            </svg>

Drop files here or click to browse

Video, audio, and image files — up to 5 GB each

          Uploading…

          0%

            Queue


OK, now I've read:
- projects.html - has a real injection bug in `binCard()` for bin names with special chars
- users.html - has injection bugs in renderUsers() and renderGroups() for names with special chars
- tokens.html - this is a fun satirical page about token pricing (a parody of Grass Valley AMPP's token model). It has no real bugs, just a humorous disclaimer at the bottom.
- upload.html - looks solid. Uses `esc()` for display, handles chunked uploads correctly. The `esc()` function here doesn't escape `'` but since there are no inline onclick handlers with user-provided string values, this is fine.

I need to check the remaining route files (bins.js, tokens.js, upload.js, groups.js) to understand the full picture, but first I should fix the injection vulnerabilities in projects.html and users.html. For projects.html, I'll replace the unsafe string concatenation in the `binCard()` function with JSON.stringify to properly escape the bin name in the onclick handler.

Fixing the users.html render functions...


Reading remaining route files:


File: services/mam-api/src/routes/bins.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';

const router = express.Router();

router.use(requireAuth);

// GET / - List bins for a project_id
router.get('/', async (req, res, next) => {
  try {
    const { project_id } = req.query;

    if (!project_id) {
      return res.status(400).json({ error: 'project_id is required' });
    }

    const result = await pool.query(
      `SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
      [project_id]
    );

    res.json(result.rows);
  } catch (err) {
    next(err);
  }
});

// POST / - Create bin
router.post('/', async (req, res, next) => {
  try {
    const { project_id, name, parent_id } = req.body;

    if (!project_id || !name) {
      return res.status(400).json({ error: 'project_id and name are required' });
    }

    const id = uuidv4();

    const result = await pool.query(
      `INSERT INTO bins (id, project_id, name, parent_id, created_at, updated_at)
       VALUES ($1, $2, $3, $4, NOW(), NOW())
       RETURNING *`,
      [id, project_id, name, parent_id || null]
    );

    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// PATCH /:id - Update bin
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { name, parent_id } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (name !== undefined) {
      updates.push(`name = $${paramCount++}`);
      params.push(name);
    }

    if (parent_id !== undefined) {
      updates.push(`parent_id = $${paramCount++}`);
      params.push(parent_id || null);
    }

    if (updates.length === 0) {
      return res.status(400).json({ error: 'No fields to update' });
    }

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const query = `
      UPDATE bins
      SET ${updates.join(', ')}
      WHERE id = $${paramCount}
      RETURNING *
    `;

    const result = await pool.query(query, params);

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Bin not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// DELETE /:id - Delete bin
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const result = await pool.query(
      'DELETE FROM bins WHERE id = $1 RETURNING *',
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Bin not found' });
    }

    res.json({ message: 'Bin deleted', bin: result.rows[0] });
  } catch (err) {
    next(err);
  }
});

// POST /:id/assets - Add asset to bin
router.post('/:id/assets', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { asset_id } = req.body;

    if (!asset_id) {
      return res.status(400).json({ error: 'asset_id is required' });
    }

    // Verify bin exists
    const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
    if (binCheck.rows.length === 0) {
      return res.status(404).json({ error: 'Bin not found' });
    }

    // Update asset's bin_id
    const result = await pool.query(
      'UPDATE assets SET bin_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
      [id, asset_id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// DELETE /:id/assets/:assetId - Remove asset from bin
router.delete('/:id/assets/:assetId', async (req, res, next) => {
  try {
    const { id, assetId } = req.params;

    // Verify bin exists
    const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
    if (binCheck.rows.length === 0) {
      return res.status(404).json({ error: 'Bin not found' });
    }

    // Remove asset from bin
    const result = await pool.query(
      'UPDATE assets SET bin_id = NULL, updated_at = NOW() WHERE id = $1 AND bin_id = $2 RETURNING *',
      [assetId, id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found in this bin' });
    }

    res.json({ message: 'Asset removed from bin', asset: result.rows[0] });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/groups.js

/**
 * Group management routes (admin-only when AUTH_ENABLED=true)
 *
 * GET    /api/v1/groups                      — list all groups
 * POST   /api/v1/groups                      — create group
 * PATCH  /api/v1/groups/:id                  — update group
 * DELETE /api/v1/groups/:id                  — delete group
 * GET    /api/v1/groups/:id/members          — list members
 * POST   /api/v1/groups/:id/members          — add member  { user_id }
 * DELETE /api/v1/groups/:id/members/:uid     — remove member
 */
import express from 'express';
import pool    from '../db/pool.js';
import { requireAuth, requireAdmin } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth, requireAdmin);

// ── List ──────────────────────────────────────────────────────
router.get('/', async (_req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT g.id, g.name, g.description, g.created_at,
              COUNT(ug.user_id)::int AS member_count
       FROM groups g
       LEFT JOIN user_groups ug ON ug.group_id = g.id
       GROUP BY g.id
       ORDER BY g.name`
    );
    res.json(rows);
  } catch (err) { next(err); }
});

// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name required' });
    const { rows } = await pool.query(
      `INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING *`,
      [name.trim(), description || null]
    );
    res.status(201).json(rows[0]);
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'Group name already exists' });
    next(err);
  }
});

// ── Update ────────────────────────────────────────────────────
router.patch('/:id', async (req, res, next) => {
  try {
    const { name, description } = req.body;
    const sets = []; const vals = [];
    if (name !== undefined)        { sets.push(`name = $${sets.length + 1}`);        vals.push(name); }
    if (description !== undefined) { sets.push(`description = $${sets.length + 1}`); vals.push(description); }
    if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
    vals.push(req.params.id);
    const { rows } = await pool.query(
      `UPDATE groups SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
      vals
    );
    if (!rows.length) return res.status(404).json({ error: 'Group not found' });
    res.json(rows[0]);
  } catch (err) { next(err); }
});

// ── Delete ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { rowCount } = await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
    if (!rowCount) return res.status(404).json({ error: 'Group not found' });
    res.json({ message: 'Group deleted' });
  } catch (err) { next(err); }
});

// ── Members ───────────────────────────────────────────────────
router.get('/:id/members', async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT u.id, u.username, u.display_name, u.role
       FROM user_groups ug
       JOIN users u ON u.id = ug.user_id
       WHERE ug.group_id = $1
       ORDER BY u.username`,
      [req.params.id]
    );
    res.json(rows);
  } catch (err) { next(err); }
});

router.post('/:id/members', async (req, res, next) => {
  try {
    const { user_id } = req.body;
    if (!user_id) return res.status(400).json({ error: 'user_id required' });
    await pool.query(
      `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
      [user_id, req.params.id]
    );
    res.status(201).json({ message: 'Member added' });
  } catch (err) { next(err); }
});

router.delete('/:id/members/:uid', async (req, res, next) => {
  try {
    await pool.query(
      `DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`,
      [req.params.id, req.params.uid]
    );
    res.json({ message: 'Member removed' });
  } catch (err) { next(err); }
});

export default router;


File: services/mam-api/src/routes/tokens.js

/**
 * Personal API token routes (requires authentication)
 *
 * GET    /api/v1/tokens      — list current user's tokens (no raw values)
 * POST   /api/v1/tokens      — create token, returns raw value ONCE
 * DELETE /api/v1/tokens/:id  — revoke token
 */
import express from 'express';
import crypto  from 'crypto';
import pool    from '../db/pool.js';

const router = express.Router();

// Helper: get current user ID from session or req.user
const userId = req => req.user?.id || req.session?.userId;

// ── List ──────────────────────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, name, token_prefix, last_used_at, expires_at, created_at
       FROM api_tokens
       WHERE user_id = $1
       ORDER BY created_at DESC`,
      [userId(req)]
    );
    res.json(rows);
  } catch (err) { next(err); }
});

// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
  try {
    const { name, expires_in_days } = req.body;
    if (!name) return res.status(400).json({ error: 'name required' });

    // Generate: wd_ + 40 random hex chars  =  43 chars total
    const raw    = 'wd_' + crypto.randomBytes(20).toString('hex');
    const hash   = crypto.createHash('sha256').update(raw).digest('hex');
    const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars

    const expiresAt = expires_in_days
      ? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000)
      : null;

    const { rows } = await pool.query(
      `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`,
      [userId(req), name.trim(), hash, prefix, expiresAt]
    );

    // Return raw token ONCE — it is never stored in plaintext
    res.status(201).json({ ...rows[0], token: raw });
  } catch (err) { next(err); }
});

// ── Revoke ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { rowCount } = await pool.query(
      `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
      [req.params.id, userId(req)]
    );
    if (!rowCount) return res.status(404).json({ error: 'Token not found' });
    res.json({ message: 'Token revoked' });
  } catch (err) { next(err); }
});

export default router;


File: services/mam-api/src/routes/upload.js

import express from 'express';
import multer from 'multer';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { s3Client, uploadStream, deleteObject, S3_BUCKET } from '../s3/client.js';
import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';

const router = express.Router();

const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage });

const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

// Only proxy queue needed here — proxy worker dispatches thumbnail once done
const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// ---------------------------------------------------------------
// AMPP Sync Helpers
// ---------------------------------------------------------------

async function resolveBinPath(binId) {
  const segments = [];
  let currentId = binId;
  while (currentId) {
    const result = await pool.query(
      'SELECT id, name, parent_id FROM bins WHERE id = $1',
      [currentId]
    );
    if (result.rows.length === 0) break;
    const bin = result.rows[0];
    segments.unshift(bin.name);
    currentId = bin.parent_id;
  }
  return segments;
}

/**
 * Fire-and-forget: mirror asset's project/bin path into AMPP folder hierarchy.
 * Never throws — failures are logged but never surface to the caller.
 */
async function syncToAmpp(assetId, projectId, binId) {
  try {
    const config = await getAmppConfig();
    if (!config) return;

    const projResult = await pool.query(
      'SELECT name FROM projects WHERE id = $1',
      [projectId]
    );
    if (projResult.rows.length === 0) return;
    const projectName = projResult.rows[0].name;

    const segments = [projectName];
    if (binId) {
      const binSegments = await resolveBinPath(binId);
      segments.push(...binSegments);
    }

    const folderId = await ensureFolderPath(config, segments);
    if (!folderId) return;

    await pool.query(
      'UPDATE assets SET ampp_folder_id = $1, ampp_synced_at = NOW() WHERE id = $2',
      [folderId, assetId]
    );

    console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`);
  } catch (err) {
    console.error(`[AMPP] sync failed for asset ${assetId}:`, err.message);
  }
}

// Derive a media_type string from a MIME type
function mediaTypeFromMime(mime = '') {
  if (mime.startsWith('video')) return 'video';
  if (mime.startsWith('audio')) return 'audio';
  if (mime.startsWith('image')) return 'image';
  return 'document';
}

// POST /api/v1/upload/init - Initialize a multipart upload
router.post('/init', async (req, res, next) => {
  try {
    const { filename, fileSize, contentType, projectId, binId, tags } = req.body;

    if (!filename || !fileSize || !contentType || !projectId) {
      return res.status(400).json({
        error: 'Missing required fields: filename, fileSize, contentType, projectId',
      });
    }

    const assetId = uuidv4();
    const s3Key   = `originals/${projectId}/${filename}`;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(contentType),
        s3Key, fileSize,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    const multipartUpload = await s3Client.send(
      new CreateMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: s3Key,
        ContentType: contentType,
      })
    );

    res.json({
      assetId,
      uploadId: multipartUpload.UploadId,
      key: s3Key,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/part - Upload a single part
router.post('/part', upload.single('file'), async (req, res, next) => {
  try {
    const { uploadId, key, partNumber } = req.body;

    if (!uploadId || !key || !partNumber || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, partNumber, and file',
      });
    }

    const partUpload = await s3Client.send(
      new UploadPartCommand({
        Bucket: S3_BUCKET,
        Key: key,
        PartNumber: parseInt(partNumber, 10),
        UploadId: uploadId,
        Body: req.file.buffer,
      })
    );

    res.json({
      partNumber: parseInt(partNumber, 10),
      etag: partUpload.ETag,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/complete - Complete the multipart upload
router.post('/complete', async (req, res, next) => {
  try {
    const { uploadId, key, assetId, parts } = req.body;

    if (!uploadId || !key || !assetId || !parts || !Array.isArray(parts)) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId, and parts array',
      });
    }

    await s3Client.send(
      new CompleteMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: key,
        UploadId: uploadId,
        MultipartUpload: {
          // Accept both casings: api.js sends ETag (uppercase), defend against both
          Parts: parts.map(p => ({
            ETag: p.ETag || p.etag,
            PartNumber: p.partNumber || p.PartNumber,
          })),
        },
      })
    );

    // Original file in S3 — queue proxy generation
    // proxy worker will dispatch thumbnail once proxy is ready
    const result = await pool.query(
      `UPDATE assets
       SET status = 'processing', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [assetId]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    const asset = result.rows[0];

    await proxyQueue.add('generate', {
      assetId,
      inputKey:  key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(asset.id, asset.project_id, asset.bin_id);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/abort - Abort upload
router.post('/abort', async (req, res, next) => {
  try {
    const { uploadId, key, assetId } = req.body;

    if (!uploadId || !key || !assetId) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId',
      });
    }

    await s3Client.send(
      new AbortMultipartUploadCommand({ Bucket: S3_BUCKET, Key: key, UploadId: uploadId })
    );

    await pool.query('DELETE FROM assets WHERE id = $1', [assetId]);

    res.json({ message: 'Upload aborted and asset deleted' });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/simple - Single-file upload for smaller files (<50 MB)
router.post('/simple', upload.single('file'), async (req, res, next) => {
  try {
    const { filename, projectId, binId, tags, contentType } = req.body;

    if (!filename || !projectId || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: filename, projectId, and file',
      });
    }

    const assetId   = uuidv4();
    const s3Key     = `originals/${projectId}/${filename}`;
    const mimeType  = contentType || req.file.mimetype;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(mimeType),
        s3Key, req.file.size,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    await uploadStream(s3Key, req.file.buffer, mimeType);

    const result = await pool.query(
      `UPDATE assets SET status = 'processing', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [assetId]
    );

    const asset = result.rows[0];

    // Queue proxy — proxy worker dispatches thumbnail on completion
    await proxyQueue.add('generate', {
      assetId,
      inputKey:  s3Key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(assetId, projectId, binId || null);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

export default router;


Let me analyze the findings:

**bins.js**: Looks good - standard CRUD. No issues found. The DELETE bin doesn't cascade to assets (just sets bin_id to null according to the description in projects.html). Actually, looking at the SQL, it just deletes the bin without touching assets. The projects.html says "Assets inside the bin will become un-binned (still in the project)." That assumes there's a foreign key constraint with ON DELETE SET NULL on assets.bin_id. I should verify but can't easily check the migration files for this. Actually looking at the `RETURNING *` on DELETE, it would succeed even if assets have bin_id pointing to it - only if there's a FK constraint.

The migrations probably handle this correctly with the appropriate constraint, so I'll move on.

**groups.js**: Looks solid. All endpoints admin-only. No issues found.

**tokens.js**:
I'm noticing a significant security gap - the routes don't have `requireAuth` middleware applied. The code extracts a userId from the request, but there's no actual authentication check enforcing that the user is logged in. This means unauthenticated requests could hit these endpoints with an undefined userId, which would cause queries to fail or behave unexpectedly rather than being properly rejected.

Looking at the other route files, they all explicitly apply `router.use(requireAuth)` at the top, but tokens.js is missing this entirely. I need to check whether index.js applies a global auth middleware that might be protecting these routes anyway, or if this is a genuine vulnerability.

The `userId` helper function extracts the user ID from the request, but if authentication fails, both `req.user?.id` and `req.session?.userId` would be undefined. When undefined gets passed to the database query, Node-postgres converts it to NULL, so a query like `WHERE user_id = NULL` would return no rows rather than causing an error or exposing data.

For POST requests, inserting a token with `user_id = NULL` would either fail if there's a NOT NULL constraint, or create an orphaned token. For DELETE, the `WHERE id = $1 AND user_id = $2` condition with NULL would never match anything, always returning 404.

The real concern is whether tokens.js has its own auth middleware or if there's a global one in index.js—I need to check that to see if unauthenticated users can actually reach these endpoints.

Looking at upload.js, the multipart S3 upload flow looks solid, but I notice the S3 key uses just `originals/${projectId}/${filename}` without a UUID, so duplicate filenames in the same project would overwrite each other. A better approach would embed the assetId in the path. multipart init also uses the same pattern. If a user uploads "wedding.mp4" twice in the same project, the second upload would overwrite the first in S3. This IS a bug - filenames aren't unique and two uploads with the same name would share the same S3 key, with the assets table having two rows pointing to the same `original_s3_key`. The fix is to include the assetId in the key like `originals/${assetId}/${filename}`, similar to how proxies are already structured.

There's also a TOCTOU gap in both flows. For simple uploads, if the uploadStream fails after the asset is inserted, it stays as 'ingesting' forever with a non-existent S3 key, and the proxy worker would fail trying to download it. For multipart uploads, if the user doesn't call abort after a failure, the multipart upload hangs in S3 indefinitely.

These are pre-existing design issues that aren't trivially fixable without a more comprehensive refactor. The duplicate S3 key issue is the more critical one to address, so I'll focus on that first. Now I need to check if index.js has global auth middleware and whether tokens.js needs requireAuth added.


Found a critical bug in tokens.js: no `requireAuth` middleware. Let me verify by reading index.js:


File: services/mam-api/src/index.js

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      // Prune expired sessions every hour
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24, // 24 h
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
// Auth routes are always open (login/logout don't require a session)
app.use('/api/v1/auth',      authRouter);

// All other routes are gated by requireAuth (no-op unless AUTH_ENABLED=true)
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});


The comment in index.js says:
```
// All other routes are gated by requireAuth (no-op unless AUTH_ENABLED=true)
```

But that's a LIE - there's no global requireAuth middleware applied in index.js! The individual routes apply their own requireAuth, but tokens.js doesn't have it.

So tokens.js is genuinely missing `requireAuth`. When `AUTH_ENABLED=true`, unauthenticated users can:
- GET /api/v1/tokens - would get empty results (WHERE user_id = NULL matches nothing)

- POST /api/v1/tokens - would try to insert with user_id = NULL, potentially creating orphan tokens or failing
- DELETE /api/v1/tokens/:id - would silently fail since WHERE user_id = NULL AND id = ? matches nothing

The real problem surfaces in production when AUTH_ENABLED=true: tokens.js runs completely unprotected, and the `userId(req)` helper returns undefined for unauthenticated requests, causing DB operations to either fail silently or behave unexpectedly.

The security concern is whether an unauthenticated user could create tokens with NULL user_id that somehow get used for authentication. Without seeing the token auth middleware, I can't be certain, but the missing auth protection is definitely a bug that needs fixing. I should add requireAuth to tokens.js and clean up that misleading comment in index.js.

Now looking at the S3 upload issue—the key format `originals/${projectId}/${filename}` doesn't include the assetId, so two uploads with the same filename in the same project would overwrite each other. I need to change it to `originals/${assetId}/${filename}` instead. But I should think through whether this key path change has any downstream implications.

Actually, the proxy worker pulls the inputKey from the job payload, which gets set during upload, so it'll automatically use whatever new key format I set. The original_s3_key stored in the database is just for reference. This change is safe for new uploads without affecting existing assets.

So my plan is to fix tokens.js with requireAuth, update the S3 key format in upload.js to include assetId, fix the binCard() injection vulnerability in projects.html, and address the renderUsers issue in users.html.

Adding the auth middleware to tokens.js...

For the upload.js fix, I need to update the S3 key format in both the `/init` and `/simple` endpoints to include the assetId in the path, which prevents collisions. The multipart flow will work correctly since the client sends the key back during `/complete`.

I'll tackle the backend fixes first, then move to the HTML injection fixes in parallel.

Now I'm looking at the projects.html file to find the exact binCard function I need to modify.

Continuing with the function code...

I see the issue now—when a bin name contains a single quote, the current escaping approach breaks the onclick handler. Using `esc(JSON.stringify(b.name))` would properly escape the name as a JSON string, which handles quotes and special characters correctly. I'm testing how JSON.stringify handles the single quote—it doesn't escape it since it's wrapped in double quotes. When I pass that stringified result through the HTML escape function, both the outer quotes and the inner apostrophe get encoded, which breaks the onclick attribute syntax.

For the case with inner double quotes, JSON.stringify properly escapes them with backslashes, and the HTML escape function then encodes those backslashes along with the quotes, creating a malformed attribute.

With the injection attempt, JSON.stringify wraps the malicious string in quotes without escaping the single quote or semicolons, leaving the payload vulnerable to breaking out of the string context in the onclick handler.

Now I'm checking the users.html file and its renderUsers function, which uses a different escape function that only handles `&`, `<`, `>`, and `"` — but not single quotes, which creates a potential vulnerability when usernames contain apostrophes. So the fix works for single quotes—the JSON stringification wraps the value in double quotes, `esc()` converts those to `"`, and the browser decodes it back to valid JavaScript with the single quote safely inside. Now I'm checking what happens with a username containing a double quote itself—JSON escapes those as `\"`, so they survive the round-trip correctly. So the escaping approach works—the browser will decode it back to a valid JavaScript string with proper backslash escaping. Now I'm applying these fixes across the codebase in two batches: first the backend files (tokens.js and upload.js), then the frontend templates. For tokens.js it's straightforward—just adding the auth middleware import and applying it to the router. For upload.js I need to add the same authentication layer.

Looking at the S3 key generation in both the /init and /simple routes, I notice they're currently using `projectId` in the path when they should be using `assetId` instead, so I'll need to update those references. Since these are targeted changes rather than full rewrites, I'll use the Forgejo API to make precise edits.

Now I need to review tokens.js and add the missing `requireAuth` middleware import and apply it to the router before making all four changes.


Confirmed: `tokens.js` has no `requireAuth` middleware (none at the router level, none globally in `index.js`). Also found that upload.js uses `originals/${projectId}/${filename}` as the S3 key — two uploads with the same filename in the same project would collide and overwrite each other. Fixing both now in parallel with the frontend injection fixes:


/**
 * Personal API token routes (requires authentication)
 *
 * GET    /api/v1/tokens      — list current user's tokens (no raw values)
 * POST   /api/v1/tokens      — create token, returns raw value ONCE
 * DELETE /api/v1/tokens/:id  — revoke token
 */
import express from 'express';
import crypto  from 'crypto';
import pool    from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth);

// Helper: get current user ID from session or req.user
const userId = req => req.user?.id || req.session?.userId;

// ── List ──────────────────────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, name, token_prefix, last_used_at, expires_at, created_at
       FROM api_tokens
       WHERE user_id = $1
       ORDER BY created_at DESC`,
      [userId(req)]
    );
    res.json(rows);
  } catch (err) { next(err); }
});

// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
  try {
    const { name, expires_in_days } = req.body;
    if (!name) return res.status(400).json({ error: 'name required' });

    // Generate: wd_ + 40 random hex chars  =  43 chars total
    const raw    = 'wd_' + crypto.randomBytes(20).toString('hex');
    const hash   = crypto.createHash('sha256').update(raw).digest('hex');
    const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars

    const expiresAt = expires_in_days
      ? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000)
      : null;

    const { rows } = await pool.query(
      `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`,
      [userId(req), name.trim(), hash, prefix, expiresAt]
    );

    // Return raw token ONCE — it is never stored in plaintext
    res.status(201).json({ ...rows[0], token: raw });
  } catch (err) { next(err); }
});

// ── Revoke ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { rowCount } = await pool.query(
      `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
      [req.params.id, userId(req)]
    );
    if (!rowCount) return res.status(404).json({ error: 'Token not found' });
    res.json({ message: 'Token revoked' });
  } catch (err) { next(err); }
});

export default router;

fix(tokens): add requireAuth middleware to token routes

Token CRUD endpoints had no authentication guard.  Without it,
unauthenticated requests could reach the handler — GET would return
empty results silently, and POST could attempt to insert a token with
user_id = NULL.  All other route files in this codebase apply
requireAuth explicitly; tokens.js was simply missing it.


OK | commit:4f8964e | Updated services/mam-api/src/routes/tokens.js


import express from 'express';
import multer from 'multer';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { s3Client, uploadStream, deleteObject, S3_BUCKET } from '../s3/client.js';
import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';

const router = express.Router();

const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage });

const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

// Only proxy queue needed here — proxy worker dispatches thumbnail once done
const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// ---------------------------------------------------------------
// AMPP Sync Helpers
// ---------------------------------------------------------------

async function resolveBinPath(binId) {
  const segments = [];
  let currentId = binId;
  while (currentId) {
    const result = await pool.query(
      'SELECT id, name, parent_id FROM bins WHERE id = $1',
      [currentId]
    );
    if (result.rows.length === 0) break;
    const bin = result.rows[0];
    segments.unshift(bin.name);
    currentId = bin.parent_id;
  }
  return segments;
}

/**
 * Fire-and-forget: mirror asset's project/bin path into AMPP folder hierarchy.
 * Never throws — failures are logged but never surface to the caller.
 */
async function syncToAmpp(assetId, projectId, binId) {
  try {
    const config = await getAmppConfig();
    if (!config) return;

    const projResult = await pool.query(
      'SELECT name FROM projects WHERE id = $1',
      [projectId]
    );
    if (projResult.rows.length === 0) return;
    const projectName = projResult.rows[0].name;

    const segments = [projectName];
    if (binId) {
      const binSegments = await resolveBinPath(binId);
      segments.push(...binSegments);
    }

    const folderId = await ensureFolderPath(config, segments);
    if (!folderId) return;

    await pool.query(
      'UPDATE assets SET ampp_folder_id = $1, ampp_synced_at = NOW() WHERE id = $2',
      [folderId, assetId]
    );

    console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`);
  } catch (err) {
    console.error(`[AMPP] sync failed for asset ${assetId}:`, err.message);
  }
}

// Derive a media_type string from a MIME type
function mediaTypeFromMime(mime = '') {
  if (mime.startsWith('video')) return 'video';
  if (mime.startsWith('audio')) return 'audio';
  if (mime.startsWith('image')) return 'image';
  return 'document';
}

// POST /api/v1/upload/init - Initialize a multipart upload
router.post('/init', async (req, res, next) => {
  try {
    const { filename, fileSize, contentType, projectId, binId, tags } = req.body;

    if (!filename || !fileSize || !contentType || !projectId) {
      return res.status(400).json({
        error: 'Missing required fields: filename, fileSize, contentType, projectId',
      });
    }

    const assetId = uuidv4();
    // Include assetId in the key so same-named files in a project never collide
    const s3Key   = `originals/${assetId}/${filename}`;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(contentType),
        s3Key, fileSize,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    const multipartUpload = await s3Client.send(
      new CreateMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: s3Key,
        ContentType: contentType,
      })
    );

    res.json({
      assetId,
      uploadId: multipartUpload.UploadId,
      key: s3Key,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/part - Upload a single part
router.post('/part', upload.single('file'), async (req, res, next) => {
  try {
    const { uploadId, key, partNumber } = req.body;

    if (!uploadId || !key || !partNumber || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, partNumber, and file',
      });
    }

    const partUpload = await s3Client.send(
      new UploadPartCommand({
        Bucket: S3_BUCKET,
        Key: key,
        PartNumber: parseInt(partNumber, 10),
        UploadId: uploadId,
        Body: req.file.buffer,
      })
    );

    res.json({
      partNumber: parseInt(partNumber, 10),
      etag: partUpload.ETag,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/complete - Complete the multipart upload
router.post('/complete', async (req, res, next) => {
  try {
    const { uploadId, key, assetId, parts } = req.body;

    if (!uploadId || !key || !assetId || !parts || !Array.isArray(parts)) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId, and parts array',
      });
    }

    await s3Client.send(
      new CompleteMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: key,
        UploadId: uploadId,
        MultipartUpload: {
          // Accept both casings: api.js sends ETag (uppercase), defend against both
          Parts: parts.map(p => ({
            ETag: p.ETag || p.etag,
            PartNumber: p.partNumber || p.PartNumber,
          })),
        },
      })
    );

    // Original file in S3 — queue proxy generation
    // proxy worker will dispatch thumbnail once proxy is ready
    const result = await pool.query(
      `UPDATE assets
       SET status = 'processing', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [assetId]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    const asset = result.rows[0];

    await proxyQueue.add('generate', {
      assetId,
      inputKey:  key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(asset.id, asset.project_id, asset.bin_id);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/abort - Abort upload
router.post('/abort', async (req, res, next) => {
  try {
    const { uploadId, key, assetId } = req.body;

    if (!uploadId || !key || !assetId) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId',
      });
    }

    await s3Client.send(
      new AbortMultipartUploadCommand({ Bucket: S3_BUCKET, Key: key, UploadId: uploadId })
    );

    await pool.query('DELETE FROM assets WHERE id = $1', [assetId]);

    res.json({ message: 'Upload aborted and asset deleted' });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/simple - Single-file upload for smaller files (<50 MB)
router.post('/simple', upload.single('file'), async (req, res, next) => {
  try {
    const { filename, projectId, binId, tags, contentType } = req.body;

    if (!filename || !projectId || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: filename, projectId, and file',
      });
    }

    const assetId   = uuidv4();
    // Include assetId in the key so same-named files in a project never collide
    const s3Key     = `originals/${assetId}/${filename}`;
    const mimeType  = contentType || req.file.mimetype;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(mimeType),
        s3Key, req.file.size,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    await uploadStream(s3Key, req.file.buffer, mimeType);

    const result = await pool.query(
      `UPDATE assets SET status = 'processing', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [assetId]
    );

    const asset = result.rows[0];

    // Queue proxy — proxy worker dispatches thumbnail on completion
    await proxyQueue.add('generate', {
      assetId,
      inputKey:  s3Key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(assetId, projectId, binId || null);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

export default router;

fix(upload): scope original S3 keys under assetId to prevent collisions

Both /init and /simple were keying originals as
`originals/${projectId}/${filename}`.  Two uploads of the same filename
into the same project would share a key — the second upload would silently
overwrite the first file in S3 while both assets remained in the DB with
the same original_s3_key.

Changed to `originals/${assetId}/${filename}` (matching the proxies/
convention) so every asset has its own unique S3 prefix.


OK | commit:58e2e53 | Updated services/mam-api/src/routes/upload.js


Good. Now let me fix the frontend injection bugs in projects.html and users.html in parallel.


Now fixing the frontend injection bugs in both HTML files in parallel:


<!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>Projects — 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>

        Projects

        <button class="btn btn-primary btn-sm" onclick="openNewProject()">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>
          New project
        </button>

          All Projects
          --

          <input type="text" id="projSearch" placeholder="Search projects…" />

Loading…

          <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4">
            <path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/>
          </svg>

Select a project on the left, or create a new one.

New project

    Name
    <input type="text" id="newProjName" placeholder="e.g. 2026 Sunday Service" autocomplete="off" />
    Description (optional)
    <textarea id="newProjDesc" rows="2" placeholder="A short note about the project"></textarea>

      <button class="btn btn-ghost btn-sm" onclick="closeModal('newProjModal')">Cancel</button>
      <button class="btn btn-primary btn-sm" onclick="createProject()">Create</button>

New bin

    Name
    <input type="text" id="newBinName" placeholder="e.g. Cameras, B-Roll, Interviews" autocomplete="off" />

      <button class="btn btn-ghost btn-sm" onclick="closeModal('newBinModal')">Cancel</button>
      <button class="btn btn-primary btn-sm" onclick="createBin()">Create</button>

fix(projects): prevent JS injection via bin names in onclick handlers

binCard() was building onclick="renameBinPrompt('id', 'NAME')" by
calling esc() then .replace(/'/g, "\\'").  The problem: esc() converts
' to ', so the replace never fires on raw single quotes.  When the
HTML parser evaluates the attribute it decodes ' back to ', breaking
the JS string — and for injected payloads like `'; alert(1)//` this is
stored XSS.

Fix: use JSON.stringify(b.name) to produce a properly-escaped double-
quoted JS string literal, then esc() to HTML-encode the surrounding
double-quotes for safe embedding in the HTML attribute.


OK | commit:1e8cde8 | Updated services/web-ui/public/projects.html


<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Users — 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>

        Users & Groups

        <button class="tab active" onclick="switchTab('users',this)">Users</button>
        <button class="tab" onclick="switchTab('groups',this)">Groups</button>

            Users
            <button class="btn btn-primary btn-sm" onclick="openUserPanel()">
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
              New user
            </button>

                Username
                Display name
                Role
                Groups
                Created

Loading…

            Groups
            <button class="btn btn-primary btn-sm" onclick="openGroupPanel()">
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
              New group
            </button>

                Name
                Description
                Members
                Created

Loading…

    New user
    <button class="btn btn-ghost btn-sm" onclick="closeUserPanel()" 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>

    <input type="hidden" id="editUserId">

      Username
      <input type="text" id="uUsername" placeholder="e.g. jsmith">

      Display name
      <input type="text" id="uDisplayName" placeholder="e.g. Jane Smith">

      Role
      <select id="uRole">
        <option value="editor">Editor</option>
        <option value="viewer">Viewer</option>
        <option value="admin">Admin</option>
      </select>

      Password
      <input type="password" id="uPassword" placeholder="Min 8 characters">

    <button class="btn btn-ghost" onclick="closeUserPanel()">Cancel</button>
    <button class="btn btn-primary" id="saveUserBtn" onclick="saveUser()">Create user</button>

    New group
    <button class="btn btn-ghost btn-sm" onclick="closeGroupPanel()" 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>

    <input type="hidden" id="editGroupId">

      Group name
      <input type="text" id="gName" placeholder="e.g. News Team">

      Description
      <textarea id="gDescription" rows="2" placeholder="Optional description"></textarea>

      Members

      <select id="addMemberSelect" style="margin-top:var(--sp-2);">
        <option value="">Add member…</option>
      </select>

    <button class="btn btn-ghost" onclick="closeGroupPanel()">Cancel</button>
    <button class="btn btn-primary" id="saveGroupBtn" onclick="saveGroup()">Create group</button>

fix(users): prevent JS injection in delete onclick handlers for users/groups

confirmDeleteUser and confirmDeleteGroup were building onclick handlers
like onclick="confirmDeleteUser('id','NAME')" using esc() which doesn't
escape single quotes.  Usernames or group names containing ' would break
the JS string; a crafted value like `'; alert(1)//` is stored XSS.

Fix: use JSON.stringify(value) to produce a properly-escaped double-quoted
JS string literal, then esc() to HTML-encode the surrounding quotes for
safe embedding in the HTML attribute.  Same technique now used in both
renderUsers() and renderGroups().


OK | commit:2bb731c | Updated services/web-ui/public/users.html


Good. 6 more commits made:
- 3c689cc: timecode.js framesToTC fix
- 0ea8d7c: timeline.js right-trim boundary fix
- 4f8964e: tokens.js requireAuth
- 58e2e53: upload.js S3 key collision fix
- 1e8cde8: projects.html bin name injection fix
- 2bb731c: users.html user/group name injection fix

That's a total of 13 commits across this session (7 from before + 6 new).

Now let me think about what else to review or what new features to develop. I've covered:

- assets.js, auth.js, bins.js, groups.js, recorders.js, sequences.js, tokens.js, upload.js, users.js, timecode.js, and timeline.js are all done
- Still need to review capture.js, jobs.js, projects.js, settings.js, and ampp.js
- For the web UI, I've checked index.html and editor.html, but there's more to go through

: edit.html (the Phase A editor), capture.html, jobs.html, projects.html, users.html, tokens.html (the satirical one), upload.html, and recorders.html

I should pull the remaining route files and web pages, focusing on the ones most likely to have issues. Let me start with the ones I haven't looked at yet.

Now I'm thinking about what new features would actually be useful beyond just fixing bugs. The system has some clear gaps—the conform worker only handles V1 tracks when the editor supports multiple tracks, the timeline ruler maxes out at 10 minutes which won't work for longer sequences, and there's no waveform visualization or undo/redo in the editor. I should prioritize features that are realistic to implement, clearly valuable for broadcast MAM workflows, and not too ambitious in scope.

Some promising candidates are duplicating sequences for quick variants, bulk operations in the library, filtering assets by processing status, showing sequences alongside bins in the projects view, adding basic undo functionality with clip state snapshots, and auto-saving sequences periodically. Before I commit to one, I want to finish reviewing the remaining route files to get the full picture of what's already there.


Reading the remaining unreviewed files:


File: services/mam-api/src/routes/jobs.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq';

const router = express.Router();
router.use(requireAuth);

// ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => {
  try {
    const parsed = new URL(url);
    return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
  } catch {
    return { host: 'localhost', port: 6379 };
  }
};

const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');

const proxyQueue     = new Queue('proxy',     { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const conformQueue   = new Queue('conform',   { connection: redisConn });

const QUEUES = [
  { queue: proxyQueue,     type: 'proxy'     },
  { queue: thumbnailQueue, type: 'thumbnail' },
  { queue: conformQueue,   type: 'conform'   },
];

// BullMQ state → API status mapping
const STATE_MAP = {
  waiting:  'waiting',
  active:   'active',
  completed:'completed',
  failed:   'failed',
  delayed:  'waiting',
  paused:   'waiting',
};

function normalizeJob(bullJob, type, apiStatus) {
  const isCompleted = apiStatus === 'completed';
  const isFailed    = apiStatus === 'failed';
  return {
    id:           `${type}:${bullJob.id}`,
    type,
    status:       apiStatus,
    progress:     typeof bullJob.progress === 'number' ? bullJob.progress : 0,
    asset_id:     bullJob.data?.assetId    || null,
    asset_name:   bullJob.data?.assetName  || null,
    created_at:   bullJob.timestamp   ? new Date(bullJob.timestamp).toISOString()   : null,
    started_at:   bullJob.processedOn ? new Date(bullJob.processedOn).toISOString() : null,
    completed_at: isCompleted && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    failed_at:    isFailed    && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    error:        bullJob.failedReason || null,
    metadata:     bullJob.data || {},
  };
}

async function getAllBullMQJobs() {
  const results = [];
  const allStates = ['waiting', 'active', 'completed', 'failed', 'delayed', 'paused'];
  for (const { queue, type } of QUEUES) {
    try {
      const jobs = await queue.getJobs(allStates, 0, 200);
      for (const job of jobs) {
        const state     = await job.getState();
        const apiStatus = STATE_MAP[state] || state;
        results.push(normalizeJob(job, type, apiStatus));
      }
    } catch {
      // queue may be unavailable – skip
    }
  }
  return results;
}

// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { type, status, asset_id } = req.query;
    let jobs = await getAllBullMQJobs();

    if (type)     jobs = jobs.filter(j => j.type === type);
    if (status)   jobs = jobs.filter(j => j.status === status);
    if (asset_id) jobs = jobs.filter(j => j.asset_id === asset_id);

    jobs.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
    res.json(jobs);
  } catch (err) {
    next(err);
  }
});

// ── GET /:id - Single job ─────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    // id format: "type:bullId"  e.g. "proxy:1"
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          const state = await job.getState();
          const apiStatus = STATE_MAP[state] || state;
          return res.json(normalizeJob(job, type, apiStatus));
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          await job.remove();
          return res.json({ success: true });
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
router.post('/conform', async (req, res, next) => {
  try {
    const { edl, project_id, output_format } = req.body;

    if (!edl || !project_id || !output_format) {
      return res.status(400).json({
        error: 'edl, project_id, and output_format are required',
      });
    }

    const bullJob = await conformQueue.add('conform-task', {
      edl,
      projectId:    project_id,
      outputFormat: output_format,
    });

    res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/projects.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';

const router = express.Router();

router.use(requireAuth);

// Helper function to slugify
const slugify = (str) => {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-');
};

// GET / - List all projects
router.get('/', async (req, res, next) => {
  try {
    const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    next(err);
  }
});

// POST / - Create project
router.post('/', async (req, res, next) => {
  try {
    const { name, description } = req.body;

    if (!name) {
      return res.status(400).json({ error: 'Name is required' });
    }

    const id = uuidv4();
    const s3_prefix = slugify(name);

    const result = await pool.query(
      `INSERT INTO projects (id, name, description, s3_prefix, created_at, updated_at)
       VALUES ($1, $2, $3, $4, NOW(), NOW())
       RETURNING *`,
      [id, name, description || null, s3_prefix]
    );

    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// GET /:id - Single project with asset count
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const result = await pool.query(
      `SELECT p.*,
              COUNT(a.id) AS asset_count
       FROM projects p
       LEFT JOIN assets a ON a.project_id = p.id AND a.status != 'archived'
       WHERE p.id = $1
       GROUP BY p.id`,
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Project not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// PATCH /:id - Update project
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { name, description } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (name !== undefined) {
      updates.push(`name = $${paramCount++}`);
      params.push(name);
    }

    if (description !== undefined) {
      updates.push(`description = $${paramCount++}`);
      params.push(description);
    }

    if (updates.length === 0) {
      return res.status(400).json({ error: 'No fields to update' });
    }

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const query = `
      UPDATE projects
      SET ${updates.join(', ')}
      WHERE id = $${paramCount}
      RETURNING *
    `;

    const result = await pool.query(query, params);

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Project not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// DELETE /:id - Delete project and cascade
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    // Delete project (cascade should handle related records)
    const result = await pool.query(
      'DELETE FROM projects WHERE id = $1 RETURNING *',
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Project not found' });
    }

    res.json({ message: 'Project deleted', project: result.rows[0] });
  } catch (err) {
    next(err);
  }
});

export default router;


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.


Let me analyze these files:

**jobs.js:** Looks solid. Has requireAuth, properly handles BullMQ queue operations, filtering by type/status/asset_id. The state mapping is correct. The conform POST endpoint has proper validation. No issues.

**projects.js:** Looks solid. The slugify function is used to create s3_prefix. Standard CRUD. The delete relies on database cascade to clean up related records (assets, bins). This is fine if FK constraints have ON DELETE CASCADE. No bugs found.

**edit.html (Phase A editor):** This is a simpler editor (single-track) that loads `js/edit.js?v=2`.

Now I need to check `js/edit.js` for any bugs. Beyond that, I'm thinking about what new features would be most valuable—undo/redo functionality for the editor would prevent accidental deletions, auto-save could protect against data loss, and there are a few other sequence-related improvements worth considering.

Undo/redo seems like the highest-impact feature since it's entirely client-side. The approach would be maintaining a stack of clip state snapshots, pushing the current state whenever clips change, and then using keyboard shortcuts to navigate between undo and redo stacks.

But I should finish reviewing edit.js first to catch any bugs, then consider what would actually help a broadcast MAM operator most. Batch retry for failed assets keeps coming up as genuinely useful—I'd need to add a retry endpoint and expose it through the library UI with a button on asset cards.


Reading the Phase A editor logic: