@podosoft/podokit 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,10 @@ declare global {
20
20
  interface Locals {
21
21
  user: SessionUser | null;
22
22
  session: { id: string; impersonatedBy?: string | null } | null;
23
+ authUnavailable: boolean;
23
24
  locale: Locale;
24
25
  site: SiteSettings | null;
26
+ siteUnavailable: boolean;
25
27
  }
26
28
  }
27
29
  }
@@ -1,8 +1,15 @@
1
- import type { Handle } from "@sveltejs/kit";
1
+ import { error, type Handle } from "@sveltejs/kit";
2
2
  import { serverApiClient } from "$lib/server/api";
3
3
  import { resolveLocale } from "$lib/i18n/messages";
4
4
  import { applySearchIndexingHeaders } from "$lib/server/search-indexing";
5
+ import { isPublicPath } from "$lib/server/guards";
5
6
  import type { SiteSettings } from "$lib/site.svelte";
7
+ import { ApiError } from "@podosoft/podokit-api-client";
8
+
9
+ function isTemporaryServiceFailure(cause: unknown): boolean {
10
+ if (!(cause instanceof ApiError)) return true;
11
+ return cause.statusCode === 408 || cause.statusCode === 429 || cause.statusCode >= 500;
12
+ }
6
13
 
7
14
  export const handle: Handle = async ({ event, resolve }) => {
8
15
  // Static assets and API proxy requests do not render application layouts.
@@ -16,23 +23,46 @@ export const handle: Handle = async ({ event, resolve }) => {
16
23
  // Public site settings (branding, locale/timezone defaults, maintenance, …),
17
24
  // loaded once here and reused by the layout load. A visitor with no locale
18
25
  // cookie falls back to the admin-configured site locale, then the app default.
26
+ event.locals.siteUnavailable = false;
19
27
  let site: SiteSettings | null = null;
20
28
  try {
21
29
  site = await serverApiClient(event).get<SiteSettings>("/site/settings");
22
- } catch {
30
+ } catch (cause: unknown) {
23
31
  site = null;
32
+ // A request-level 4xx proves that the backend is reachable. In particular,
33
+ // the require-2FA guard returns 403 until enrolment and must remain able to
34
+ // redirect the user to /setup-2fa instead of being masked as an outage.
35
+ event.locals.siteUnavailable = isTemporaryServiceFailure(cause);
24
36
  }
25
37
  event.locals.site = site;
26
38
  event.locals.locale = resolveLocale(event.cookies.get("locale") ?? site?.locale ?? undefined);
39
+
40
+ event.locals.user = null;
41
+ event.locals.session = null;
42
+ event.locals.authUnavailable = false;
27
43
  try {
28
- const { data } = await serverApiClient(event).auth.getSession();
29
- event.locals.user = (data?.user as App.Locals["user"]) ?? null;
30
- event.locals.session = data?.session
31
- ? { id: data.session.id, impersonatedBy: (data.session as { impersonatedBy?: string | null }).impersonatedBy ?? null }
32
- : null;
44
+ const { data, error } = await serverApiClient(event).auth.getSession();
45
+ // Better Auth returns HTTP authentication failures as data/error. A 401 is
46
+ // a confirmed anonymous session; every other error means that the session
47
+ // could not be checked and must not be treated as a logout.
48
+ if (error && error.status !== 401) {
49
+ event.locals.authUnavailable = true;
50
+ } else {
51
+ event.locals.user = (data?.user as App.Locals["user"]) ?? null;
52
+ event.locals.session = data?.session
53
+ ? { id: data.session.id, impersonatedBy: (data.session as { impersonatedBy?: string | null }).impersonatedBy ?? null }
54
+ : null;
55
+ }
33
56
  } catch {
34
- event.locals.user = null;
35
- event.locals.session = null;
57
+ // Transport failures do not invalidate the browser's session cookie. A
58
+ // protected loader will fail closed with 503 until the backend recovers.
59
+ event.locals.authUnavailable = true;
60
+ }
61
+ if (
62
+ (event.locals.authUnavailable || event.locals.siteUnavailable) &&
63
+ !isPublicPath(event.url.pathname)
64
+ ) {
65
+ error(503, "Service temporarily unavailable");
36
66
  }
37
67
  const response = await resolve(event, {
38
68
  transformPageChunk: ({ html }) =>
@@ -4,8 +4,34 @@ import { error } from "@sveltejs/kit";
4
4
  * added here (and, later, replaced by a permission check) without touching every
5
5
  * page loader. better-auth stores a user's role(s) as a comma-separated string. */
6
6
  const ADMIN_ROLES = ["admin"];
7
+ const PUBLIC_PATHS = [
8
+ "/",
9
+ "/login",
10
+ "/signup",
11
+ "/forgot-password",
12
+ "/reset-password",
13
+ "/verify-email",
14
+ "/pending-approval",
15
+ "/maintenance",
16
+ // Modules add their public (no-session) page prefixes here.
17
+ // podokit:begin:public-paths
18
+ // podokit:end:public-paths
19
+ ];
7
20
 
8
21
  type WithRole = { role?: string | null } | null | undefined;
22
+ type BackendAvailability = Pick<App.Locals, "authUnavailable" | "siteUnavailable">;
23
+
24
+ /** True for routes that may render without a confirmed session or site policy. */
25
+ export function isPublicPath(pathname: string): boolean {
26
+ return PUBLIC_PATHS.some((path) => pathname === path || (path !== "/" && pathname.startsWith(`${path}/`)));
27
+ }
28
+
29
+ /** Stop protected loaders when authentication or runtime policy cannot be checked. */
30
+ export function requireBackendAvailable(locals: BackendAvailability): void {
31
+ if (locals.authUnavailable || locals.siteUnavailable) {
32
+ error(503, "Service temporarily unavailable");
33
+ }
34
+ }
9
35
 
10
36
  /** True when the user holds at least one admin role. */
11
37
  export function isAdmin(user: WithRole): boolean {
@@ -16,7 +42,8 @@ export function isAdmin(user: WithRole): boolean {
16
42
  return roles.some((r) => ADMIN_ROLES.includes(r));
17
43
  }
18
44
 
19
- /** Guard an admin-only loader: throws 403 unless the user holds an admin role. */
20
- export function requireAdmin(user: WithRole): void {
45
+ /** Guard an admin-only loader after confirming the backend is available. */
46
+ export function requireAdmin(user: WithRole, availability?: BackendAvailability): void {
47
+ if (availability) requireBackendAvailable(availability);
21
48
  if (!isAdmin(user)) error(403, "Admins only");
22
49
  }
@@ -1,8 +1,11 @@
1
1
  import { redirect } from "@sveltejs/kit";
2
2
  import type { LayoutServerLoad } from "./$types";
3
3
  import type { Capabilities } from "@podosoft/podokit-api-client";
4
+ import { requireBackendAvailable } from "$lib/server/guards";
4
5
 
5
6
  export const load: LayoutServerLoad = async ({ locals, fetch }) => {
7
+ requireBackendAvailable(locals);
8
+
6
9
  let capabilities: Capabilities = {
7
10
  twoFactor: false,
8
11
  providers: [],
@@ -2,5 +2,5 @@ import type { PageServerLoad } from "./$types";
2
2
  import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- requireAdmin(locals.user);
5
+ requireAdmin(locals.user, locals);
6
6
  };
@@ -2,5 +2,5 @@ import type { PageServerLoad } from "./$types";
2
2
  import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- requireAdmin(locals.user);
5
+ requireAdmin(locals.user, locals);
6
6
  };
@@ -2,6 +2,6 @@ import type { PageServerLoad } from "./$types";
2
2
  import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- requireAdmin(locals.user);
5
+ requireAdmin(locals.user, locals);
6
6
  return { currentSessionId: locals.session?.id ?? null };
7
7
  };
@@ -14,7 +14,7 @@ export type AuthConfigView = {
14
14
  };
15
15
 
16
16
  export const load: PageServerLoad = async ({ locals, fetch }) => {
17
- requireAdmin(locals.user);
17
+ requireAdmin(locals.user, locals);
18
18
  let authConfig: AuthConfigView | null = null;
19
19
  try {
20
20
  const res = await fetch("/api/account/auth-config");
@@ -2,6 +2,6 @@ import type { PageServerLoad } from "./$types";
2
2
  import { requireAdmin } from "$lib/server/guards";
3
3
 
4
4
  export const load: PageServerLoad = ({ locals }) => {
5
- requireAdmin(locals.user);
5
+ requireAdmin(locals.user, locals);
6
6
  return { currentUserId: locals.user!.id };
7
7
  };
@@ -2,29 +2,13 @@ import type { LayoutServerLoad } from "./$types";
2
2
  import { redirect } from "@sveltejs/kit";
3
3
  import { safeAuthRedirect } from "$lib/auth-redirect";
4
4
  import { loadMessages } from "$lib/i18n/messages";
5
+ import { isPublicPath, requireBackendAvailable } from "$lib/server/guards";
5
6
 
6
7
  // "/" is a public landing page. Auth pages return signed-in users to their safe
7
8
  // requested page, and the admin area requires a session. /maintenance is public
8
9
  // so it renders while the rest of the site is held back.
9
- const PUBLIC_PATHS = [
10
- "/",
11
- "/login",
12
- "/signup",
13
- "/forgot-password",
14
- "/reset-password",
15
- "/verify-email",
16
- "/pending-approval",
17
- "/maintenance",
18
- // Modules add their public (no-session) page prefixes here.
19
- // podokit:begin:public-paths
20
- // podokit:end:public-paths
21
- ];
22
10
  const AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password"];
23
11
 
24
- function isPublic(pathname: string): boolean {
25
- return PUBLIC_PATHS.some((path) => pathname === path || (path !== "/" && pathname.startsWith(`${path}/`)));
26
- }
27
-
28
12
  export const load: LayoutServerLoad = async (event) => {
29
13
  const { locals, url } = event;
30
14
  const { pathname } = url;
@@ -32,13 +16,18 @@ export const load: LayoutServerLoad = async (event) => {
32
16
  const site = locals.site;
33
17
  const isAdmin = locals.user?.role === "admin";
34
18
 
19
+ // Authentication and maintenance policy are both security boundaries. If
20
+ // either could not be checked, deny protected content without redirecting to
21
+ // login or modifying the session cookie. Public pages keep their static fallbacks.
22
+ if (!isPublicPath(pathname)) requireBackendAvailable(locals);
23
+
35
24
  // Maintenance mode holds everyone but admins on a maintenance page. /login and
36
25
  // /maintenance stay reachable so an admin can still sign in and turn it off.
37
26
  const maintenance = site?.maintenanceMode === "true";
38
27
  if (maintenance && !isAdmin && pathname !== "/maintenance" && pathname !== "/login") {
39
28
  redirect(303, "/maintenance");
40
29
  }
41
- if (!maintenance && pathname === "/maintenance") {
30
+ if (!locals.siteUnavailable && !maintenance && pathname === "/maintenance") {
42
31
  redirect(303, "/");
43
32
  }
44
33
  // Registration closed from the admin Settings: send /signup back to /login.
@@ -50,7 +39,7 @@ export const load: LayoutServerLoad = async (event) => {
50
39
  if (locals.user && AUTH_PATHS.includes(pathname)) {
51
40
  redirect(303, safeAuthRedirect(url.searchParams.get("redirect")));
52
41
  }
53
- if (!locals.user && !isPublic(pathname)) {
42
+ if (!locals.user && !isPublicPath(pathname)) {
54
43
  redirect(303, `/login?redirect=${encodeURIComponent(`${pathname}${url.search}`)}`);
55
44
  }
56
45
  const messages = await loadMessages(locals.locale, site?.locale);
@@ -1,9 +1,11 @@
1
1
  import { redirect } from "@sveltejs/kit";
2
2
  import type { PageServerLoad } from "./$types";
3
+ import { requireBackendAvailable } from "$lib/server/guards";
3
4
 
4
5
  // The mandatory two-factor enrolment page (reached via the require-2fa gate).
5
6
  // Must be signed in; already-enrolled users have nothing to do here.
6
7
  export const load: PageServerLoad = ({ locals }) => {
8
+ requireBackendAvailable(locals);
7
9
  if (!locals.user) redirect(302, "/login");
8
10
  const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean };
9
11
  if (user.twoFactorEnabled) redirect(302, "/admin");
@@ -17,6 +17,19 @@
17
17
  "apps/web/src/lib/i18n/locales/**",
18
18
  "apps/web/src/lib/i18n/catalogs/app/**"
19
19
  ],
20
+ "managedOverrides": [
21
+ "apps/web/src/routes/(app)/+layout.server.ts",
22
+ "apps/web/src/routes/(app)/admin/account/+page.server.ts",
23
+ "apps/web/src/routes/(app)/admin/audit/+page.server.ts",
24
+ "apps/web/src/routes/(app)/admin/organizations/+page.server.ts",
25
+ "apps/web/src/routes/(app)/admin/sessions/+page.server.ts",
26
+ "apps/web/src/routes/(app)/admin/settings/+page.server.ts",
27
+ "apps/web/src/routes/(app)/admin/users/+page.server.ts",
28
+ "apps/web/src/routes/(auth)/login/+page.server.ts",
29
+ "apps/web/src/routes/(auth)/signup/+page.server.ts",
30
+ "apps/web/src/routes/account/+page.server.ts",
31
+ "apps/web/src/routes/setup-2fa/+page.server.ts"
32
+ ],
20
33
  "packageOverlays": {
21
34
  "api": {
22
35
  "scripts": {
@@ -53,7 +66,7 @@
53
66
  "file": "AGENTS.md",
54
67
  "marker": "<!-- podokit:end:agents-modules -->",
55
68
  "optional": true,
56
- "text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
69
+ "text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `$lib/server/guards` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
57
70
  },
58
71
  {
59
72
  "file": "apps/api/src/app.module.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {