@podosoft/podokit 0.12.1 → 0.12.3

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");
@@ -53,7 +53,7 @@
53
53
  "file": "AGENTS.md",
54
54
  "marker": "<!-- podokit:end:agents-modules -->",
55
55
  "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"
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- 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
57
  },
58
58
  {
59
59
  "file": "apps/api/src/app.module.ts",
package/dist/update.d.ts CHANGED
@@ -28,6 +28,8 @@ export declare function planUpdate(projectRoot: string, templatesDir: string): U
28
28
  export interface ApplyOptions {
29
29
  /** Templates for the version the project is currently on, for 3-way merges. */
30
30
  oldTemplatesDir?: string;
31
+ /** Preinstalled previous external modules. Primarily useful for offline updates. */
32
+ oldExternalModulesRoot?: string;
31
33
  }
32
34
  export interface ApplyResult {
33
35
  written: string[];
package/dist/update.js CHANGED
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.planUpdate = planUpdate;
4
4
  exports.applyUpdate = applyUpdate;
5
5
  exports.summarize = summarize;
6
+ const node_child_process_1 = require("node:child_process");
6
7
  const node_fs_1 = require("node:fs");
8
+ const node_os_1 = require("node:os");
7
9
  const node_path_1 = require("node:path");
8
10
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
9
11
  const assemble_1 = require("./assemble");
@@ -124,20 +126,48 @@ function planUpdate(projectRoot, templatesDir) {
124
126
  changes,
125
127
  };
126
128
  }
127
- function assertPreviousExternalModulesInstalled(projectRoot, templatesDir, modules) {
129
+ function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
128
130
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
129
131
  for (const module of modules) {
130
132
  if (!module.packageName || !module.moduleVersion)
131
133
  continue;
132
- const resolved = (0, add_1.resolveModule)(module.packageName, modulesDir, projectRoot);
133
- if (!resolved?.moduleVersion || resolved.moduleVersion === module.moduleVersion)
134
+ const resolved = (0, add_1.resolveModule)(module.packageName, modulesDir, moduleRoot);
135
+ if (resolved?.moduleVersion === module.moduleVersion)
134
136
  continue;
135
- throw new Error(`Cannot use --from while external module "${module.packageName}" is installed at ` +
136
- `${resolved.moduleVersion} but the project records ${module.moduleVersion}. Restore ` +
137
- `${module.packageName}@${module.moduleVersion}, update PodoKit with --from, then upgrade ` +
138
- "external modules separately.");
137
+ const actual = resolved?.moduleVersion ?? "not installed";
138
+ throw new Error(`Previous external module root has ${module.packageName} at ${actual}; ` +
139
+ `expected ${module.moduleVersion} from the project manifest.`);
139
140
  }
140
141
  }
142
+ function previousExternalModules(projectRoot, templatesDir, modules, providedRoot) {
143
+ const external = modules.filter((module) => Boolean(module.packageName && module.moduleVersion));
144
+ if (!external.length)
145
+ return { root: projectRoot, cleanup: false };
146
+ if (providedRoot) {
147
+ assertRecordedExternalModules(providedRoot, templatesDir, external);
148
+ return { root: providedRoot, cleanup: false };
149
+ }
150
+ const currentMatchesRecorded = external.every((module) => {
151
+ const resolved = (0, add_1.resolveModule)(module.packageName, (0, node_path_1.join)(templatesDir, "modules"), projectRoot);
152
+ return resolved?.moduleVersion === module.moduleVersion;
153
+ });
154
+ if (currentMatchesRecorded)
155
+ return { root: projectRoot, cleanup: false };
156
+ const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-update-modules-"));
157
+ const devDependencies = Object.fromEntries(external.map((module) => [module.packageName, module.moduleVersion]));
158
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, "package.json"), `${JSON.stringify({ name: "podokit-update-modules", private: true, devDependencies }, null, 2)}\n`);
159
+ try {
160
+ (0, node_child_process_1.execFileSync)("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"], { cwd: root, stdio: "pipe" });
161
+ assertRecordedExternalModules(root, templatesDir, external);
162
+ }
163
+ catch {
164
+ (0, node_fs_1.rmSync)(root, { recursive: true, force: true });
165
+ const expected = external.map((module) => `${module.packageName}@${module.moduleVersion}`).join(", ");
166
+ throw new Error(`Cannot install previous external modules (${expected}) for the 3-way merge. ` +
167
+ "Check npm registry access and try again.");
168
+ }
169
+ return { root, cleanup: true };
170
+ }
141
171
  function writeFile(projectRoot, path, content) {
142
172
  const abs = (0, node_path_1.join)(projectRoot, path);
143
173
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(abs), { recursive: true });
@@ -203,18 +233,23 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
203
233
  const managedOverrides = targetManagedOverrides(projectRoot, templatesDir, modules, manifest.managedOverrides);
204
234
  const ownedGlobs = targetOwnedGlobs(projectRoot, templatesDir, modules, manifest.ownedGlobs);
205
235
  const needsMergeBase = plan.changes.some((change) => change.action === "conflict");
236
+ let oldTree = null;
206
237
  if (options.oldTemplatesDir && needsMergeBase) {
207
- assertPreviousExternalModulesInstalled(projectRoot, templatesDir, modules);
238
+ const previousModules = previousExternalModules(projectRoot, options.oldTemplatesDir, modules, options.oldExternalModulesRoot);
239
+ try {
240
+ oldTree = (0, assemble_1.assembleProject)({
241
+ templatesDir: options.oldTemplatesDir,
242
+ template: manifest.template,
243
+ answers: manifest.answers,
244
+ modules,
245
+ projectRoot: previousModules.root,
246
+ });
247
+ }
248
+ finally {
249
+ if (previousModules.cleanup)
250
+ (0, node_fs_1.rmSync)(previousModules.root, { recursive: true, force: true });
251
+ }
208
252
  }
209
- const oldTree = options.oldTemplatesDir && needsMergeBase
210
- ? (0, assemble_1.assembleProject)({
211
- templatesDir: options.oldTemplatesDir,
212
- template: manifest.template,
213
- answers: manifest.answers,
214
- modules,
215
- projectRoot,
216
- })
217
- : null;
218
253
  const result = { written: [], removed: [], merged: [], conflicts: [] };
219
254
  for (const change of plan.changes) {
220
255
  const newText = treeText(newTree, change.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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": {