Operability: - Correlation IDs end-to-end: shared dispatch_id between log lines and EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths) and a new X-Request-Id middleware that normalizes inbound ids and binds request_id into log context. - dispatch_summary block merged into EventLog.details: per-target success/failure counts plus Telegram media delivered/skipped/failed and truncated error lists, so partial outcomes surface in the UI. - Diagnostic mode: admin can flip one module to DEBUG for a bounded window with auto-revert (in-memory only; setup_logging() resets on boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints plus DiagnosticsCassette UI on the settings page. Telegram: - Per-receiver options: disable_notification (silent send) and message_thread_id (forum-topic routing), wired through the dispatcher via a ContextVar so all four send sites (sendMessage / sendPhoto-Video- Document / sendMediaGroup / cache-hit POST) pick them up. - send_large_videos_as_documents target setting: bypass the 50 MB sendVideo cap by falling back to sendDocument for oversized videos. - sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES, 45 MB) with per-item fallback on chunk failure so a stale file_id no longer silently drops a cached asset. Tests: - New: diagnostic_mode, dispatch_summary, request_correlation, telegram_media_group_partial, telegram_per_send_options. Docs: - .claude/reviews/: six-axis production-readiness review of v0.8.1. - .claude/docs/functional-review-2026-05-28.md: focused review of Telegram/Immich/logging subsystems.
29 KiB
Frontend Production-Readiness Review
Scope: frontend/src/** (~26k lines, Svelte 5 runes + SvelteKit). npm run check
passes with exit code 0. The codebase is in good shape overall - i18n EN/RU keys
are 1:1 in sync (1466 each), Modal/Snackbar overlays follow the position:fixed
z-index:9999convention, noeval, noinnerHTML, no string-interpolatedsetTimeout, and the sanitizer (lib/sanitize.ts) is a sound DOMParser-based allowlist. The issues below are real production risks layered on top of an otherwise clean architecture.
Executive Summary
- Auth tokens live in
localStorage(lib/api.ts). Any XSS that bypasses the (good)sanitizePreviewallowlist - or sneaks past it via a future code path - exfiltrates both access and refresh tokens. There is no httpOnly-cookie alternative, no token rotation on refresh failure, andredirectToLoginonly fires once per session (a leaked refresh token can outlive that flag). - One real provider-hardcoding violation (
routes/actions/RuleEditor.svelte) breaks the "descriptors only" rule in CLAUDE.md item 8 and silently disables the people/album picker for any non-Immich provider - every other page is clean. - Caches duplicated into local
$stateonnotification-trackers,command-trackers, andcommand-template-configspages - the cache is populated but the page never re-reads it, so cross-page mutations (search palette pre-warming) won't update the list and cacheinvalidate()becomes useless. Convention #4 says "always use cache". - Three CRUD pages refetch all entities after every mutation (full
await load()after upsert/delete) instead of usingcache.upsert()/remove()- defeats the optimistic-cache design and produces visible flicker on slow connections. - Floating async work + N+1 patterns:
providers/+page.sveltefires N parallel health checks without an AbortController (state writes continue after navigation);bots/TelegramBotTab.sveltedoes a sequentialfor (const trk of trackers) { await api('/listeners') }loop. backup/+page.sveltepost-restart health poll keeps recursing for up to 120s with no unmount guard - if the user navigates away mid-restart, the recursivesetTimeoutchain keeps callingfetch('/api/health')until it reloads the page out from under whatever route they're on.api()30s timeout is per-request, hard-coded, with no observability - long-running provider operations (Immich bulk fetch, full backup export) hit it silently and surface asAbortErrorwith no telemetry.
CRITICAL
C1. JWT tokens stored in localStorage - XSS-exfiltratable
function getToken(): string | null {
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
Both the short-lived access token and the long-lived refresh token sit in
localStorage. Any successful XSS - including a future template-preview path
that escapes sanitizePreview, a vulnerable third-party CodeMirror extension,
or a Telegram bot username that ends up unescaped somewhere - reads both with a
single localStorage.getItem call.
Fix: Move to httpOnly + Secure + SameSite=Strict cookies set by the backend.
If a cookie-based session is infeasible for the deployment model, at minimum
move the refresh token to an httpOnly cookie and keep only the short-lived
access token in memory (a module-level let accessToken is XSS-readable but
not persistent across reloads, which limits the exfiltration window).
C2. Provider type hardcoded in RuleEditor.svelte (convention violation)
routes/actions/RuleEditor.svelte:55-67
async function loadProviderData() {
if (actionType !== 'auto_organize') return;
const provider = providersCache.items.find((p: any) => p.id === providerId);
if (!provider || provider.type !== 'immich') return;
...
CLAUDE.md item 8 explicitly forbids if (type === 'immich') in components -
this is the canonical example. As written, adding a second provider with
auto-organize support (Google Photos, future SmugMug, etc.) is a silent no-op:
the form renders with empty people/album lists and gives no error.
Fix: Add an actionTypes / peopleFilter capability flag to
ProviderDescriptor, or add a supportsAutoOrganize: boolean discriminator,
then check getDescriptor(provider.type)?.supportsAutoOrganize instead of the
literal string.
HIGH
H1. Caches imported but copied into local $state - invalidation no-op
routes/notification-trackers/+page.svelte:33 routes/command-trackers/+page.svelte:27 routes/command-template-configs/+page.svelte:51
// notification-trackers - line 33
let allNotificationTrackers = $state<Tracker[]>([]);
// ...
[allNotificationTrackers] = await Promise.all([
api<Tracker[]>('/notification-trackers'),
...
]);
The cache modules expose notificationTrackersCache, commandTrackersCache,
and commandTemplateConfigsCache - populated by +layout.svelte on mount and
by the search palette - but these three pages don't read from them. They each
issue their own api(...) call and store the result locally. Side effects:
- The cache shows stale data on every other page that reads it (dashboard nav counts, search palette).
commandTemplateConfigsCache.fetch(true)is called oncommand-template-configsload()but the result is then re-assigned from the function return value intoallCmdTplConfigs- the cache itself is updated, but the page has no reactive link to it.cache.upsert()/cache.remove()after mutations would short-circuit a full refetch - but with the local-state copy, every save triggers a fullawait load()(see H2).
Fix: Replace let allX = $state([]) with let allX = $derived(cache.items)
(see how targets/+page.svelte:147 does it correctly) and remove the parallel
api() call.
H2. Full refetch after every mutation - cache.upsert/remove not used
routes/providers/+page.svelte:238-250 routes/actions/+page.svelte:139 routes/notification-trackers/+page.svelte:291 routes/targets/+page.svelte:476
Every save/delete/toggle on these pages calls cache.invalidate(); await load(),
which re-fetches the entire list from the server. The cache exposes
upsert(entity) and remove(id) for exactly this case - the server already
returned the new entity (or 204), so the round-trip is wasted bandwidth and
produces a visible "list redraws" flash on slow links.
Fix: On POST/PUT response, cache.upsert(savedEntity). On DELETE,
cache.remove(id). Reserve invalidate() + fetch() for cases where the
mutation may have changed other entities (e.g. broadcast target updates
affect children).
H3. Provider health checks fire-and-forget - leak past navigation
routes/providers/+page.svelte:175-181
for (const p of allProviders) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
.catch(() => { health = { ...health, [p.id]: false }; });
}
No AbortController, no unmount guard. If the user navigates away while N
slow Immich/Gitea probes are inflight, every probe still resolves and tries to
write to the (now-detached) health $state. With Svelte 5 runes this won't
crash, but it does waste backend connections (Immich health checks call the
real API) and may trigger duplicate probes on quick back/forward navigation.
Fix: Pass { signal: controller.signal } to api() (already supported -
see lib/api.ts:150), abort in onDestroy. Or use cache.probeAll() driven
from a single store so revisiting the page reuses the previous result.
H4. Sequential awaits for independent fetches - N+1 in TelegramBotTab
routes/bots/TelegramBotTab.svelte:215-223
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
try {
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some(...);
if (hasBot) matched.push(trk);
} catch (e) { console.warn(...); }
}
For a deployment with 20 command trackers, opening the listener section on a
bot triggers 20 serial GET /command-trackers/{id}/listeners requests -
visibly slow over a high-latency link.
Fix: Either expose a single backend endpoint
(GET /command-trackers/listeners?bot_id=X) or run the loop through
Promise.all(trackers.map(trk => api(...).catch(() => null))) and filter
afterwards.
H5. Post-restart health poll keeps running after unmount
routes/settings/backup/+page.svelte:117-139
async function applyAndRestart(): Promise<void> {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
const startedAt = Date.now();
let attempts = 0;
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
}
The recursive setTimeout(poll, 1000) chain has no cancellation. If the user
navigates to another route between apply-restart and the next health probe,
the chain keeps firing for up to 120s and eventually calls
window.location.reload() from a route the user has since moved away from.
Side effects:
- Unauthenticated
fetch('/api/health')calls keep going while the user is on/login. - A user who hit "restart later" on a different tab will still get reloaded from the original tab's poll.
Fix: Capture controller = new AbortController() and pass to fetch,
onDestroy(() => controller.abort()). Also store the timeout handle and
clearTimeout it on destroy.
H6. Token refresh races with logout in a sneaky edge
The dedupe via refreshPromise is correct for the refresh itself, but the
outer api() reads getToken() before awaiting refreshAccessToken(). Three
concurrent requests that all 401 will all queue on the same refresh promise,
then all retry - fine. But if the refresh succeeds and an unrelated
clearTokens() (from logout()) fires between the refresh resolving and the
retry running, the retry uses an empty Authorization: Bearer header. The
result is "ApiError: HTTP 401" surfaced via snackbar even though the redirect
to /login already happened.
Fix: Either re-check isAuthenticated() immediately before the retry, or
make clearTokens() cancel an inflight refreshPromise.
H7. AuthRedirectError is thrown but not consistently caught
Most pages use the pattern catch (err: unknown) { snackError(errMsg(err)); } -
which catches AuthRedirectError too and shows "Unauthorized - redirecting
to login" in a snackbar that the user sees as the route changes. The error
class exists specifically to be distinguished, but only one or two call sites
actually check instanceof AuthRedirectError before showing a snackbar.
Fix: Make errMsg() (or a new helper) return null for AuthRedirectError
and have snackbar helpers ignore null messages. Or filter in the snackbar
store.
H8. api() JSON-decode failure path swallowed silently
return res.json();
When the backend returns a 200 OK with a non-JSON body (proxy error page,
HTML 502 from a misconfigured reverse proxy in front), res.json() rejects
with a SyntaxError: Unexpected token < in JSON at position 0. The page
shows the raw parser message in a snackbar, which is confusing UX.
Fix: Wrap res.json() in try/catch and throw a typed ApiError("Backend returned non-JSON response", 502) so the UI can show a clean message.
H9. Email/Matrix bot tabs strip secrets via as any
routes/bots/EmailBotTab.svelte:84 routes/bots/MatrixBotTab.svelte:79
if (!body.smtp_password) delete (body as any).smtp_password;
if (editingMatrix && !body.access_token) delete (body as any).access_token;
The as any bypass exists because the body type doesn't allow delete on a
required field. The intent - "don't send a blank secret which would overwrite
the stored one" - is correct, but the cast hides a real risk: if the field
name ever changes (smtp_password -> smtpPassword), the delete is a no-op
and the blank field is sent.
Fix: Build body as Partial<...> from the start and only conditionally
include the secret field.
H10. template-configs hardcodes a slot name
routes/template-configs/+page.svelte:228
.map(s => ({ key: s.name, label: ..., rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
Special-casing one Immich slot name inside a provider-agnostic component is the same pattern CLAUDE.md item 8 forbids for components, scoped to template configs. Other providers' "large" slots (Gitea PR descriptions, Planka card content) would render in 3-row editors that the author probably didn't intend.
Fix: Add a rows?: number field to the backend slot definition and read
it via notification_slots[].rows.
MEDIUM
M1. Three placeholder strings hardcoded English in shared components
lib/components/EntitySelect.svelte:18 lib/components/IconGridSelect.svelte:16 lib/components/MultiEntitySelect.svelte:16
placeholder = 'Select...',
These defaults render Select... in RU locale when a caller doesn't pass an
explicit placeholder. The convention (CLAUDE.md item 5) prescribes plain text
selectors but says nothing about translation - these still need to flow through
t().
Fix: Move the default into the template: placeholder = $props().placeholder ?? t('common.selectPlaceholder'), with common.selectPlaceholder added to
both locales.
M2. EntitySelect.noneLabel defaults to a decorative em-dash literal
lib/components/EntitySelect.svelte:20
noneLabel = (em-dash literal),
CLAUDE.md item 5 calls out decorative dashes specifically. LinkedTargetsSection
already overrides this with t('common.noneDefault') (good), but other
consumers that do not override get the bare em-dash. It also fails the
localizable smell test.
Fix: Default to t('common.none').
M3. lib/auth.svelte.ts logout does a full page reload, losing UX continuity
export function logout() {
clearTokens();
clearAllCaches();
user = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
window.location.href triggers a hard reload - the SvelteKit router exists
specifically to avoid this. Side effects: any inflight requests get cancelled
without proper cleanup, the splash-loader flashes between the two pages, and
the search-palette / overlays do not get a chance to close gracefully.
Fix: goto('/login', { invalidateAll: true, replaceState: true }).
M4. +layout.svelte auto-expand $effect writes during read
The effect reads expandedGroups (via expandedGroups[entry.key]) and writes
to expandedGroups. Svelte 5 dedupes the write back to the same set of keys,
but the pattern is fragile - adding any side effect that re-derives from
expandedGroups here would loop. It also persists to localStorage in
toggleGroup but not from this effect - so auto-expansion stays in memory only.
Fix: Compute the next state in a single pass and write once; either include the localStorage save, or move the auto-expand into the initial hydration block.
M5. commandTemplateConfigsCache.fetch(true) result discarded; cache populated but unused
routes/command-template-configs/+page.svelte:208
The Promise.all destructures cfgs from commandTemplateConfigsCache.fetch(true)
but then writes allCmdTplConfigs = cfgs instead of $derived-reading the cache.
The cache is updated (good) but this page never reads it (bad - see H1).
Fix: Same fix as H1 - use $derived(commandTemplateConfigsCache.items).
M6. Dashboard search debounce timeout not cleared on filter change
If the user changes the type/provider filter (applyFilters runs synchronously
from the $effect at line 249) while a search debounce is pending, the pending
timeout still fires 300ms later and triggers an identical request. Not a leak,
just a wasted call.
Fix: Clear searchTimeout from applyFilters() as well.
M7. Dashboard Promise.all destructure uses empty middle slot
const [statusRes, , chartRes] = await Promise.all([
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<{ days: ... }>('/status/chart'),
]);
The empty middle slot is brittle - anyone reordering for readability silently
swaps statusRes and chartRes. Trivially avoided.
Fix: Either await providersCache.fetch() separately (it caches anyway),
or const [statusRes, _providers, chartRes] = ... with an explicit _providers
local.
M8. actions/+page.svelte derives actionTypes from a function-in-derived
routes/actions/+page.svelte:78-81
let actionTypes = $derived((() => {
const caps = capabilitiesCache.items[selectedProviderType];
return caps?.action_types || [];
})());
The IIFE is unnecessary; $derived already runs the expression on every
dependency change. Reads as a refactor leftover.
Fix: let actionTypes = $derived(capabilitiesCache.items[selectedProviderType]?.action_types ?? []);
M9. RuleEditor.svelte mutates rule object in toggleRule then sends to API
routes/actions/RuleEditor.svelte:105-108
async function toggleRule(rule: ActionRule) {
rule.enabled = !rule.enabled;
await updateRule(rule);
}
Direct mutation of the prop violates the immutability rule (coding-style.md). If the API call fails, the local state is already flipped - the UI shows the new value even though the server still has the old one.
Fix: await updateRule({ ...rule, enabled: !rule.enabled }). After
successful response, await loadRules() (already happens) re-syncs.
M10. +layout.svelte filter functions use as any[] four times
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
The cast exists because filterById<T extends { provider_id?: number }> is
narrower than the cache item types. The proper fix is a single base interface
{ provider_id?: number } on the relevant types so the cast goes away.
M11. setLocale does not update <html lang> attr
lib/i18n/index.svelte.ts:31-36
Screen readers and browser translation extensions rely on <html lang="en">.
The app never sets it, so switching to RU leaves accessibility tooling thinking
the page is still English.
Fix: document.documentElement.lang = locale in setLocale.
M12. Modal.svelte focus restore does not verify element still in DOM
lib/components/Modal.svelte:43-45
If the previously focused element has been removed from the DOM between modal
open and close (common with optimistic UI updates that rerender the source
button), .focus() is a silent no-op on a detached node. Focus ends up on
<body> and the next Tab restarts from the top of the page.
Fix: if (... && document.contains(previouslyFocused)) previouslyFocused.focus(),
else focus a sensible fallback (the trigger that opened the page).
M13. TimezoneSelector ticks at 1s - wakes the event loop forever
lib/components/TimezoneSelector.svelte:33-37
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
A 1Hz tick is fine for visible UI; the issue is it keeps running even when the selector dropdown is closed (the time display is only visible when the dropdown is open). Battery impact is non-trivial on mobile for what is essentially a hidden component.
Fix: Start/stop the interval based on open state, or use
requestAnimationFrame driven by IntersectionObserver.
M14. Backup file download builds blob from JSON without size guard
routes/settings/backup/+page.svelte:269-281
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
For a deployment with hundreds of providers/trackers, the JSON serialization
of the entire backup happens in-memory in a single string before the Blob
constructor - wasted memory peak and a frozen tab on slow machines. Worse,
api() parses the JSON and then JSON.stringify re-serializes it.
Fix: Use fetchAuth() for the download path and pipe the response stream
straight into a Blob (new Blob([await res.arrayBuffer()])).
M15. Modal focus-trap query selector includes disabled inputs
lib/components/Modal.svelte:62-67
Re-querying the DOM on every Tab keystroke is OK but means disabled inputs
(common in long forms with submit-in-progress) are included in the trap and
focus can land on them. The selector should add :not([disabled]).
M16. i18n resolve uses any for the recursion accumulator
lib/i18n/index.svelte.ts:55-62
function resolve(obj: any, path: string): string | undefined {
obj: unknown plus a runtime check would let TS narrow current properly and
catch the case where someone accidentally passes a string (returns undefined
silently today).
M17. Tracker name auto-set string concat - English-only
routes/notification-trackers/+page.svelte:82-84 routes/command-trackers/+page.svelte:69-71
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
form.name = provider ? `${provider.name} Commands` : 'Commands';
Defaults the tracker name to "Provider Name Tracker" / "Provider Name Commands"
- only English. Russian users get an English suffix on the auto-generated name. Inconsistent with the rest of the i18n discipline.
Fix: Use t('notificationTracker.defaultName').replace('{name}', provider.name).
M18. topbar-action store not cleared on auth state change
routes/providers/+page.svelte:160-167
Each page sets a topbar CTA in onMount and clears it in onDestroy. If
logout() is called from inside the page (via the search palette, etc.), the
page never destroys cleanly and the topbar action sticks into the login screen.
Defensive topbarAction.clear() in logout() would plug this.
M19. Many : any and as any types in critical paths
routes/users/+page.svelte:62 routes/command-trackers/+page.svelte:27 routes/providers/+page.svelte:179 lib/providers/types.ts:120
64 occurrences of : any / as any across 20 files. None are in
security-sensitive paths, but they remove type safety in exactly the call
sites that shape API requests (body: any = { ... }). Recommended cleanup
task, not a blocker.
LOW
L1. +page.svelte event types hardcoded in three parallel maps
eventLabels, eventIcons, and eventGradients are three parallel dicts
keyed by the same set of strings. Adding a new event type requires editing
three places (plus i18n). A single EVENT_META object would be more
maintainable.
L2. TestMenu.svelte uses z-index 9998 instead of 9999
routes/notification-trackers/TestMenu.svelte:25
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
The convention says 9999 for overlays. Using 9998 was probably intentional (so the menu sits above the backdrop), but the cleaner pattern is to give the backdrop a slightly lower stacking context inside the same parent.
L3. console.warn left in production-bound code
14 console.warn/console.error occurrences. Most are guarded by a
"failed to load" + UI fallback - legitimate debug noise. Recommend wiring to
a structured logger before public release; current state is acceptable for an
internal tool but spam-prone in DevTools.
L4. Dashboard setTimeout(animateCount, 200) is uncancelled
The 200ms delay before triggering count animations is uncancelled. Navigating
away during the first 200ms means the count animation requestAnimationFrame
chain still runs against a stale status reference. Cosmetic only.
L5. app.html inline theme bootstrap reads localStorage without try/catch
Theme is hydrated synchronously in <head> to avoid FOUC - fine - but if
localStorage is blocked (Safari private mode, some enterprise policies) the
inline script throws and the rest of the head bootstrap may be skipped.
L6. EventChart computes activeTypes and hasData from same loop twice
lib/components/EventChart.svelte:46-49
hasData and activeTypes traverse the same data twice. Single-pass
derivation would be cheaper for the rare "many days of events" case.
L7. Single-letter t shadowing in +layout.svelte
+layout.svelte:140 uses for (const t of targets) inside navCounts, which
shadows the imported i18n function t. Svelte 5 does not flag it (inner scope
wins), but it confuses search/grep and breaks IDE go-to-definition. Several
other pages use single-letter t as iteration var (actions/+page.svelte,
command-trackers/+page.svelte, targets/+page.svelte). Recommend target /
tracker for legibility.
Notes & non-findings
- Modal overlay convention (CLAUDE.md #2): Modal.svelte, Snackbar,
IconPicker, IconGridSelect, MultiEntitySelect, EntitySelect, TimezoneSelector,
EventChart, Hint, SearchPalette, and TestMenu all use
position:fixedwithz-index: 9999(or 9998 for the TestMenu backdrop - see L2). Convention upheld. - @html usage - only three call sites, all pipe through
sanitizePreview, which is a DOMParser-based allowlist limited toB,I,CODE,PRE,A,BRwithhttps?://href validation. Safe. - i18n parity: EN and RU JSON have the exact same 1466 keys - no orphans.
- Selector placeholders:
LinkedTargetsSectioncorrectly usest('common.noneDefault'), no em-dash leaks in user-facing flows (only defaults inside shared components - see M1/M2). - svelte-check passes (exit 0) - no type errors at the strict level the project compiles with.
- No eval, new Function, or string-setTimeout: dynamic code execution surface is clean.
- No var declarations, no
==(loose equality) outside generated CSS. - AbortController usage: present in
lib/api.tsfor the canonical fetch wrapper - the rest of the codebase could lean on it more (see H3, H5).