check:boundary passed on the very leak it was written for. The pattern was anchored to `includeItemTypes:` at the query site, so searchScope.ts assigning the same array to a named const and dereferencing it one indirection away was invisible — through every green CI run. The check now matches an array literal naming two or more Jellyfin item types anywhere in src/, catching a const, a Record value, a function return, and an inline query alike. Deliberate limits kept: two adjacent literals required (single-type presentation stays legal), string literals required (item.type === "Audio" is display logic), explicit type list (so ["High","Low"] produces no noise). Verified all five cases: reintroducing the original SCOPE_ITEM_TYPES fails; a new const ["Movie","Series"] fails; the same array in a .test.ts passes; itemType: "Movie" / item.type === / ["High","Low"] pass; a 5th allowlist entry fails on the new cap. Allowlist 1→3 entries, capped at 4 so the next exception forces a conversation rather than a one-line append: - GenericMediaListPage: grid styling over a self-declared itemType — presentation, changes only with a UI redesign. - DownloadedBrowse: borderline, leans domain (the container set grows when Jellyfin adds a container type). Allowlisted with a TODO for a backend MediaItem.isContainer flag. The header now names what the check still cannot see — run-time-built sets, types split across variables, switch/|| taxonomy — and CLAUDE.md states that a green check:boundary is not proof. That matters given this check passed on its own founding violation for months. Also: both gates wired into test-all.sh, which called `bun run test` without --run and would have hung in watch mode. Corrected the Dockerfile comment describing the Windows toolchain as mingw/GNU — it is MSVC via cargo-xwin (GNU cannot bundle NSIS from Linux).
141 lines
6.5 KiB
Bash
Executable File
141 lines
6.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
|
#
|
|
# Implements DR-094 (see docs/requirements.md).
|
|
#
|
|
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
|
# the frontend is presentation-only and the Rust backend owns domain logic —
|
|
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
|
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
|
# that motivated this check.
|
|
#
|
|
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
|
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
|
# targets the machine-detectable signature of the leak class and defers
|
|
# everything subtler to the human spec-review checklist
|
|
# (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here does not mean the
|
|
# boundary is respected; it means the crudest violation isn't present.
|
|
#
|
|
# What it flags: an array literal naming two or more Jellyfin item types,
|
|
# ANYWHERE in src/ — i.e. the frontend deciding that a *category* maps to a *set*
|
|
# of Jellyfin types, which is domain knowledge the backend should own.
|
|
# Single-type arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show
|
|
# movies" and are allowed. Type *inspection* (`item.type === "Audio"`) is display
|
|
# logic and is not matched.
|
|
#
|
|
# 🔴 What it still CANNOT see (do not read a green run as proof):
|
|
# - a type set built at run time: [...musicTypes, "Playlist"]
|
|
# - types split across variables: const A = "Audio"; [A, B]
|
|
# - taxonomy as control flow: switch (t) { case "Audio": … }
|
|
# t === "Audio" || t === "MusicAlbum"
|
|
# - an item type absent from ITEM_TYPES below (false negative by design)
|
|
#
|
|
# This check was hardened in July 2026 after the audit found it passing on the
|
|
# very leak it was written for: the original pattern was anchored to
|
|
# `includeItemTypes:` at the query site, so assigning the same array to a named
|
|
# const evaded it entirely. See docs/specs/boundary-tripwire-hardening.md (DR-094).
|
|
#
|
|
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
|
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
# Files permitted to contain a multi-type item-type array, with the reason.
|
|
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
|
# signal to push taxonomy into Rust, not to keep appending here.
|
|
ALLOWLIST=(
|
|
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
|
# two-type filmography query with no category-configuration behind it. Tracked
|
|
# as acceptable pending any person-scope work; revisit if it grows.
|
|
"src/lib/components/library/PersonDetailView.svelte"
|
|
|
|
# Grid styling predicate over `config.itemType`, a value the page already
|
|
# declares about itself. Selects a *look*, issues no query, and would only
|
|
# change if the UI were redesigned — presentation, not taxonomy-as-policy.
|
|
"src/lib/components/library/GenericMediaListPage.svelte"
|
|
|
|
# "Is this item a container?" predicate for downloads browsing.
|
|
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
|
|
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
|
|
# flag and remove this entry. Tracked in
|
|
# docs/specs/boundary-tripwire-hardening.md §Out of scope.
|
|
"src/lib/components/downloads/DownloadedBrowse.svelte"
|
|
)
|
|
|
|
# Hard cap so erosion is caught mechanically rather than by whoever notices.
|
|
# Deliberately just above the current count: the next exception forces a
|
|
# conversation instead of a one-line append.
|
|
MAX_ALLOWLIST=4
|
|
|
|
if [[ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]]; then
|
|
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
|
echo " Push taxonomy into Rust instead of appending here."
|
|
exit 1
|
|
fi
|
|
|
|
is_allowed() {
|
|
local file="$1"
|
|
for allowed in "${ALLOWLIST[@]}"; do
|
|
[[ "$file" == "$allowed" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
|
|
#
|
|
# NOT anchored to `includeItemTypes:` — that was the original rule, and it missed
|
|
# the real leak: `searchScope.ts` assigned the same array to a named const and
|
|
# dereferenced it one indirection away from the query, so the grep never saw it
|
|
# while CI stayed green. Matching the array literal itself catches a const, a
|
|
# Record value, a function return, and an inline query alike.
|
|
#
|
|
# Deliberate limits:
|
|
# - requires TWO adjacent types, so single-type presentation
|
|
# (`itemType: "Movie"`) stays legal — the rule targets *category* taxonomy;
|
|
# - requires string literals, so `item.type === "Audio"` (display inspection)
|
|
# does not match;
|
|
# - uses an explicit type list rather than a generic capitalised-word pattern,
|
|
# so unrelated string arrays (`["High","Low"]`) produce no noise.
|
|
#
|
|
# An item type missing from this list is a false *negative*, never a false
|
|
# positive — the check degrades safely as Jellyfin adds types.
|
|
ITEM_TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
|
PATTERN="\[[[:space:]]*\"($ITEM_TYPES)\"[[:space:]]*,[[:space:]]*\"($ITEM_TYPES)\""
|
|
|
|
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
|
|
|
|
# Collect hits, excluding tests and the allowlist.
|
|
violations=""
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
file="${line%%:*}"
|
|
case "$file" in
|
|
*.test.*) continue ;;
|
|
esac
|
|
if is_allowed "$file"; then
|
|
echo " ⏭️ allowlisted: $line"
|
|
continue
|
|
fi
|
|
violations+="$line"$'\n'
|
|
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
|
|
|
if [[ -n "$violations" ]]; then
|
|
echo ""
|
|
echo "❌ Frontend boundary violation: an item-type array literal defines a"
|
|
echo " category in the presentation layer. That taxonomy belongs in Rust —"
|
|
echo " send an opaque scope/enum and let the backend expand it to item types"
|
|
echo " (see SearchScope::item_types() in src-tauri/src/repository/types.rs)."
|
|
echo " Assigning the array to a const does not make it presentation."
|
|
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
|
echo ""
|
|
echo "$violations" | sed 's/^/ /'
|
|
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
|
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
|
exit 1
|
|
fi
|
|
|
|
echo "✅ No multi-type taxonomy queries in the frontend."
|
|
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
|
echo " the real gate for subtler leaks.)"
|