Files
siftlode/frontend/src/lib/api/refusalCodes.ts
T
peter 4379f76747 feat(i18n): every server refusal is said by a record the contract holds
A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
2026-10-30 23:36:25 +01:00

105 lines
6.5 KiB
TypeScript

// Which sentence answers each server refusal (`{ code, reason, … }`), as data: `localizeDetail` says a
// refusal through these records, and `i18n/backendIds.contract.test.ts` reads every code and reason the
// backend can send and requires each to land here with its sentence in both languages. A refusal that
// fell through to the generic "couldn't carry out that action" used to stay unnoticed — the language
// parity test is green when EN and HU miss the same key.
//
// No imports: the contract test reads this module in a plain node run, without i18n.
/** A code whose reasons each name their own sentence: `<prefix>.<reason><suffix>`. */
export type RefusalFamily = { prefix: string; suffix?: string; fallback: string };
export const REFUSAL_FAMILIES = {
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and the
// rest of the detail its values.
refused: { prefix: "errors.refused", fallback: "errors.generic" },
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it — wait
// for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
youtube: { prefix: "errors.youtube", fallback: "errors.youtube.unavailable" },
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's row
// 404s the same way); `reason` names WHAT is gone.
not_found: { prefix: "errors.notFound", fallback: "errors.notFound.generic" },
// A clip the server will not cut (downloads/clip): the range, the frame, the source; a cap comes
// with the refusal (`limit_s`).
clip: { prefix: "clip.errors", fallback: "clip.failed" },
// An admin action on an account or an invite: an address no longer approved, a link or invitation
// with nothing to go to or no mail to go by. The toast's title names the button; this is the WHY,
// which used to be the generic "try again" no retry could fix. One namespace for the three codes.
activate: { prefix: "errors.admin", fallback: "errors.generic" },
resend: { prefix: "errors.admin", fallback: "errors.generic" },
invite_send: { prefix: "errors.admin", fallback: "errors.generic" },
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
playlist: { prefix: "playlists.errors", fallback: "errors.generic" },
// The one-step "move to another list": both reasons mean the list moved under you, named apart.
playlist_move: { prefix: "playlists.moveErrors", fallback: "playlists.moveFailed" },
edit: { prefix: "downloads.errors.edit", fallback: "downloads.errors.edit.generic" },
cookies: { prefix: "downloads.errors.cookies", fallback: "downloads.errors.cookies.generic" },
// A download URL the server refuses to fetch. The reasons are deliberately distinct — "we could not
// look that host up" is a different thing to tell someone than "we will not fetch from that address".
url_blocked: {
prefix: "downloads.errors.urlBlocked",
fallback: "downloads.errors.urlBlocked.generic",
},
// Password sign-in refusals, in one line. The sign-in card renders the account-shaped ones as a full
// panel off `detailData`; this is the form every other consumer (and the card's fallback) gets.
login: {
prefix: "welcome.auth.blocked",
suffix: ".short",
fallback: "welcome.auth.genericError",
},
} satisfies Record<string, RefusalFamily>;
/** A code whose few reasons share sentences: each reason the backend sends is named here. */
export const REFUSAL_BY_REASON = {
quota: {
reasons: {
max_bytes: "downloads.errors.quota.max_bytes",
max_jobs: "downloads.errors.quota.max_jobs",
},
fallback: "downloads.errors.quota.generic",
},
// The set-password link. Its failure is one a user meets by simply clicking an old link, so it may
// not answer in hardcoded English under a bilingual UI.
reset: {
reasons: { weak_password: "welcome.auth.weakPassword", invalid: "welcome.auth.deadLink.short" },
fallback: "welcome.auth.deadLink.short",
},
// A Plex collection name already taken in that library (the one collection-edit refusal).
collection: {
reasons: { name_taken: "plex.collection.nameTaken" },
fallback: "plex.collection.editFailed",
},
} satisfies Record<string, { reasons: Record<string, string>; fallback: string }>;
/** A code with one sentence, whatever else it carries. */
export const REFUSAL_FIXED = {
// The email confirmation link — like `reset`, met by clicking an old link.
verify: "welcome.auth.verifyInvalid",
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
upload_too_large: "downloads.errors.upload.tooLarge",
upload_empty: "downloads.errors.upload.empty",
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. On a DEFAULT install it is what an over-cap upload gets, because the ceiling and the
// route's own cap are the same figure and the edge answers first — the numbered `upload_too_large`
// answers only once an admin has LOWERED the knob (bodylimit.ROUTE_MAX_BODY_BYTES).
// A BACKSTOP, not the plan: the middleware answers without draining, so whether the browser reads
// this at all depends on the client having finished sending (readable up to 200 MiB over loopback,
// a bare `onerror` through a dev proxy). That is why the upload dialog checks the file against
// `upload_max_bytes` BEFORE sending. The sentence stays PAYLOAD-NEUTRAL: a JSON form gets it too
// (`debug_sessions` refuses an over-size draft with the same code), and advice about a file told the
// issue composer's user to pick a smaller one — of nothing.
too_large: "errors.tooLarge",
// A manual sync trigger on cooldown: a pace limit rather than an error, and the copy says so.
sync_cooldown: "errors.syncCooldown",
} satisfies Record<string, string>;
/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */
export const REFUSAL_UNSHOWN = {
subtitle: "answers a <track> src; the player polls the track's status and never reads this body",
json_required: "answers the client log beacon, which the UI always sends as JSON",
diagnostics_off: "answers the client log beacon; the client stops streaming on it, silently",
rate_limited: "answers the client log beacon over its rate; dropped lines are not reported",
bad_cursor: "a list cursor the UI itself wrote; only a hand-edited request gets this",
invalid_input: "a NUL byte in a request, which no form of the UI can send",
} satisfies Record<string, string>;