fix(stage-2): audit dashboard TS alignment + login UX

Closes 4 audit findings:

- Route TS interface now carries dispatch_mode (sync|async). Backend's
  shared::route::Route has had this since 0012_routes_dispatch_mode but
  the TS client silently always created sync routes and the list
  display dropped the field. Add a Dispatch select to the new-route
  form and an "ASYNC" badge in the route list.

- api.files.downloadUrl pointed to a never-registered backend endpoint.
  The dashboard's live Download button was hitting 405. Add the GET
  handler (AppFilesRead + FilesRepo::head + FilesRepo::get, Content-
  Disposition: inline) at the same path that delete already used.

- F-T-003: adminRequest's 401 handler called goto(login) without a
  recursion cap. If the login endpoint itself returned 401, the wrapper
  looped until browser nav limits. Track consecutive 401s within a 10s
  window and hard-reload to /login?reason=auth-loop after the third,
  showing the user an explanatory banner. Any 2xx resets the counter.

- Login flow now honors a ?returnTo= query parameter. adminRequest and
  the root layout both append the current location when redirecting to
  /login; the login page validates the value is a same-origin admin
  path (no open-redirect) and goto's there after successful sign-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-10 21:09:53 +02:00
parent 3c9816daf3
commit f466b2a15e
5 changed files with 166 additions and 10 deletions

View File

@@ -100,6 +100,7 @@ export interface PatchAppInput {
export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export type DispatchMode = 'sync' | 'async';
export interface Route {
id: string;
@@ -111,6 +112,7 @@ export interface Route {
path_kind: PathKind;
path: string;
method: string | null;
dispatch_mode: DispatchMode;
created_at: string;
}
@@ -121,6 +123,7 @@ export interface RouteInput {
path_kind: PathKind;
path: string;
method?: string | null;
dispatch_mode?: DispatchMode;
}
export interface CheckRouteResponse {
@@ -461,6 +464,27 @@ export class ApiError extends Error {
}
}
// F-T-003: if the server keeps returning 401 (e.g. the bounce to
// /login itself triggered another 401-emitting request), the previous
// implementation would just keep calling `goto(login)` — a tight loop.
// Track consecutive 401s; after the threshold within the window, hard-
// reload to a static auth-loop page so the user sees something other
// than a spinning dashboard. Any 2xx response resets the count.
let consecutive401Count = 0;
let first401At = 0;
const AUTH_LOOP_THRESHOLD = 3;
const AUTH_LOOP_WINDOW_MS = 10_000;
function loginUrlWithReturnTo(): string {
if (!browser) return `${base}/login`;
const here = `${window.location.pathname}${window.location.search}`;
// Don't loop returnTo back to /login itself.
if (here === `${base}/login` || here.startsWith(`${base}/login?`)) {
return `${base}/login`;
}
return `${base}/login?returnTo=${encodeURIComponent(here)}`;
}
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
'content-type': 'application/json',
@@ -478,9 +502,27 @@ async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
// and bounce to login — unless we're already on it, in which
// case throw and let the login form render the error.
clearSession();
if (browser && !window.location.pathname.endsWith('/login')) {
void goto(`${base}/login`);
const now = Date.now();
if (now - first401At > AUTH_LOOP_WINDOW_MS) {
first401At = now;
consecutive401Count = 1;
} else {
consecutive401Count += 1;
}
if (browser && !window.location.pathname.endsWith('/login')) {
if (consecutive401Count >= AUTH_LOOP_THRESHOLD) {
// Hard-reload via location.assign — Svelte's goto keeps
// the SPA state which is what's keeping the loop alive.
window.location.assign(`${base}/login?reason=auth-loop`);
} else {
void goto(loginUrlWithReturnTo());
}
}
} else if (res.ok) {
// Any successful response resets the loop counter — the user has
// a valid session again.
consecutive401Count = 0;
first401At = 0;
}
if (!res.ok) {
const message =
@@ -908,8 +950,9 @@ export const api = {
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
{ method: 'DELETE' }
),
/// F-U-009: build the admin download URL. GET returns the file
/// bytes inline; the browser handles content-disposition.
// Build the admin download URL. GET returns the file bytes
// inline with Content-Disposition set from the stored filename;
// the browser handles the download.
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
},