@podosoft/podokit 1.2.0 → 1.4.0

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.
Files changed (187) hide show
  1. package/dist/add.d.ts +13 -2
  2. package/dist/add.js +135 -20
  3. package/dist/api-paths.d.ts +34 -0
  4. package/dist/api-paths.js +103 -0
  5. package/dist/deploy-compose-profile.d.ts +4 -0
  6. package/dist/deploy-compose-profile.js +13 -2
  7. package/dist/deploy-compose-render.d.ts +1 -1
  8. package/dist/deploy-compose-render.js +23 -7
  9. package/dist/deploy-compose.js +2 -1
  10. package/dist/deploy-profile.d.ts +3 -0
  11. package/dist/deploy-profile.js +12 -4
  12. package/dist/deploy-render.js +54 -8
  13. package/dist/deploy.js +21 -1
  14. package/dist/dev.d.ts +14 -0
  15. package/dist/dev.js +152 -10
  16. package/dist/index.js +23 -2
  17. package/dist/lib.d.ts +4 -2
  18. package/dist/lib.js +5 -2
  19. package/dist/lockfile.d.ts +6 -1
  20. package/dist/lockfile.js +26 -6
  21. package/dist/module-api-paths.d.ts +52 -0
  22. package/dist/module-api-paths.js +189 -0
  23. package/dist/remove.d.ts +3 -0
  24. package/dist/remove.js +12 -3
  25. package/dist/templates/fullstack/AGENTS.md +17 -2
  26. package/dist/templates/fullstack/README.md +19 -5
  27. package/dist/templates/fullstack/apps/api/package.json +1 -1
  28. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  29. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  30. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  31. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  32. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  39. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  40. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  41. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  42. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  43. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  44. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  45. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  46. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  47. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  49. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  50. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  51. package/dist/templates/fullstack/apps/web/components.json +3 -1
  52. package/dist/templates/fullstack/apps/web/package.json +1 -1
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  71. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  72. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  88. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  89. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  90. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  91. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  93. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
  94. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  95. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  96. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  98. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  99. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  100. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  103. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  104. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  105. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  108. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  109. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  110. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  113. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  114. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  115. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  116. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  117. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  118. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  119. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  120. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  121. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  122. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  123. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  124. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  125. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  126. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  127. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  128. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  129. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  130. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  131. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  132. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  133. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  134. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  135. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  136. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  137. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  138. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  139. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  140. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  141. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  142. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  143. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  144. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  145. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  146. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  147. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  148. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  149. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  150. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  151. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  152. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  153. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  154. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  155. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  156. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  157. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  158. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  159. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  160. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  161. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  162. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  163. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  164. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  165. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  166. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  167. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  168. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  169. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  170. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  171. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  172. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  173. package/dist/templates/modules/redis/module.manifest.json +4 -0
  174. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  175. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  176. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  177. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
  178. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
  179. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  180. package/dist/templates/modules/sse/module.manifest.json +3 -1
  181. package/dist/templates/todo/AGENTS.md +9 -1
  182. package/dist/templates/todo/README.md +18 -5
  183. package/dist/update.d.ts +22 -0
  184. package/dist/update.js +68 -8
  185. package/package.json +2 -2
  186. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  187. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -0,0 +1,60 @@
1
+ // Bundled entry for scripts/bootstrap-admin.mjs. The build bundles only its
2
+ // top-level targets, so the script cannot import auth modules from dist directly.
3
+ // The command runs against a migrated schema and reports a missing table from its
4
+ // own query. Better Auth's asynchronous startup check would only race the
5
+ // connection the command closes when it finishes.
6
+ import "./auth/maintenance-command";
7
+ import { verifyPassword } from "better-auth/crypto";
8
+ import { auth } from "./auth/auth";
9
+ import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
10
+
11
+ export interface BootstrapUser {
12
+ id: string;
13
+ email: string;
14
+ role: string | null;
15
+ emailVerified: boolean;
16
+ signupApproved: boolean;
17
+ passwordHash: string | null;
18
+ }
19
+
20
+ interface BootstrapUserRow extends Omit<BootstrapUser, "emailVerified" | "signupApproved"> {
21
+ // SQLite stores booleans as 0 or 1.
22
+ emailVerified: boolean | number | null;
23
+ signupApproved: boolean | number | null;
24
+ }
25
+
26
+ function userQuery(placeholder: string): string {
27
+ return `SELECT u.id,
28
+ u.email,
29
+ u.role,
30
+ u."emailVerified" AS "emailVerified",
31
+ u."signupApproved" AS "signupApproved",
32
+ a.password AS "passwordHash"
33
+ FROM "user" AS u
34
+ LEFT JOIN "account" AS a
35
+ ON a."userId" = u.id
36
+ AND a."providerId" = 'credential'
37
+ WHERE lower(u.email) = lower(${placeholder})
38
+ LIMIT 1`;
39
+ }
40
+
41
+ function flag(value: boolean | number | null): boolean {
42
+ return value === true || value === 1;
43
+ }
44
+
45
+ export async function findBootstrapUser(email: string): Promise<BootstrapUser | null> {
46
+ let row: BootstrapUserRow | undefined;
47
+ if (postgresPool) {
48
+ row = (await postgresPool.query<BootstrapUserRow>(userQuery("$1"), [email])).rows[0];
49
+ } else if (sqliteDatabase) {
50
+ row = sqliteDatabase.query<BootstrapUserRow, [string]>(userQuery("?1")).get(email) ?? undefined;
51
+ }
52
+ if (!row) return null;
53
+ return {
54
+ ...row,
55
+ emailVerified: flag(row.emailVerified),
56
+ signupApproved: flag(row.signupApproved),
57
+ };
58
+ }
59
+
60
+ export { auth, closeAuthDatabase, verifyPassword };
@@ -0,0 +1,35 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ // The admin session list pages active sessions newest first with a keyset on
4
+ // ("createdAt", "id"). Better Auth's schema indexes only "userId" and "token",
5
+ // so without this index every page sorts the whole session table.
6
+ const INDEX = "IDX_session_createdAt_id";
7
+
8
+ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
+ name = "IndexSessionCreatedAt1792060000000";
10
+ // CONCURRENTLY cannot run inside a transaction block. The runner therefore
11
+ // uses the "each" transaction mode. SQLite migrations always use one.
12
+ transaction = false;
13
+
14
+ async up(queryRunner: QueryRunner): Promise<void> {
15
+ if (queryRunner.connection.options.type !== "postgres") {
16
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
17
+ return;
18
+ }
19
+ // Sign-ins keep writing sessions while the index builds. An interrupted
20
+ // build leaves an invalid index that IF NOT EXISTS would keep.
21
+ const invalid = await queryRunner.query(
22
+ `SELECT 1 AS "invalid" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid`,
23
+ [`"${INDEX}"`],
24
+ ) as unknown[];
25
+ if (invalid.length > 0) await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${INDEX}"`);
26
+ await queryRunner.query(
27
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`,
28
+ );
29
+ }
30
+
31
+ // A revert runs inside a transaction, so it uses a plain statement.
32
+ async down(queryRunner: QueryRunner): Promise<void> {
33
+ await queryRunner.query(`DROP INDEX IF EXISTS "${INDEX}"`);
34
+ }
35
+ }
@@ -1,9 +1,12 @@
1
- import { error } from "@sveltejs/kit";
1
+ import { error, type RequestEvent } from "@sveltejs/kit";
2
2
  import type { Handle } from "@sveltejs/kit/hooks";
3
+ import { building } from "$app/env";
4
+ import { routes } from "$app/manifest";
3
5
  import { serverApiClient } from "#lib/server/api.js";
4
6
  import { resolveLocale } from "#lib/i18n/messages.js";
5
7
  import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
6
8
  import { isPublicPath } from "#lib/server/guards.js";
9
+ import { needsRuntimeContext } from "#lib/server/runtime-context.js";
7
10
  import type { SiteSettings } from "#lib/site.svelte.js";
8
11
  import { ApiError } from "@podosoft/podokit-api-client";
9
12
 
@@ -12,15 +15,18 @@ function isTemporaryServiceFailure(cause: unknown): boolean {
12
15
  return cause.statusCode === 408 || cause.statusCode === 429 || cause.statusCode >= 500;
13
16
  }
14
17
 
15
- export const handle: Handle = async ({ event, resolve }) => {
16
- // Static assets and API proxy requests do not render application layouts.
17
- // Avoid multiplying settings/session API calls for every asset and proxied
18
- // request; page and data routes still receive the complete runtime context.
19
- if (event.route.id === null || event.url.pathname.startsWith("/api/")) {
20
- const response = await resolve(event);
21
- return applySearchIndexingHeaders(response, event.url.pathname);
22
- }
18
+ // An anonymous visitor with no site settings, for responses that render no
19
+ // layout. The locale still follows the visitor's cookie.
20
+ function setEmptyRuntimeContext(event: RequestEvent): void {
21
+ event.locals.site = null;
22
+ event.locals.siteUnavailable = false;
23
+ event.locals.user = null;
24
+ event.locals.session = null;
25
+ event.locals.authUnavailable = false;
26
+ event.locals.locale = resolveLocale(event.cookies.get("locale"));
27
+ }
23
28
 
29
+ async function loadRuntimeContext(event: RequestEvent): Promise<void> {
24
30
  // Public site settings (branding, locale/timezone defaults, maintenance, …)
25
31
  // and the session are independent, so both requests run at once. The
26
32
  // settings are loaded once here and reused by the layout load. The async
@@ -73,6 +79,18 @@ export const handle: Handle = async ({ event, resolve }) => {
73
79
  ) {
74
80
  error(503, "Service temporarily unavailable");
75
81
  }
82
+ }
83
+
84
+ export const handle: Handle = async ({ event, resolve }) => {
85
+ // Listed asset endpoints, missing subresources, the API proxy, and
86
+ // prerendering skip the two API lookups. Pages, their data, form actions,
87
+ // remote functions, and the 404 page get the complete runtime context.
88
+ const route = routes.find(({ id }) => id === event.route.id);
89
+ if (needsRuntimeContext(event, { building, route })) {
90
+ await loadRuntimeContext(event);
91
+ } else {
92
+ setEmptyRuntimeContext(event);
93
+ }
76
94
  const response = await resolve(event, {
77
95
  transformPageChunk: ({ html }) =>
78
96
  html.replace(
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { keepLatestFilter, listUsersQuery, type UserListState } from "./users";
2
+ import {
3
+ adminUsersQuery,
4
+ keepLatestFilter,
5
+ listAdminUsers,
6
+ listUsersQuery,
7
+ type UserListState,
8
+ } from "./users";
3
9
 
4
10
  const base: UserListState = {
5
11
  page: 1,
@@ -11,9 +17,9 @@ const base: UserListState = {
11
17
  status: "",
12
18
  };
13
19
 
14
- describe("listUsersQuery", () => {
20
+ describe("adminUsersQuery", () => {
15
21
  it("pages newest first by default", () => {
16
- expect(listUsersQuery({ ...base, page: 3 })).toEqual({
22
+ expect(adminUsersQuery({ ...base, page: 3 })).toEqual({
17
23
  limit: 15,
18
24
  offset: 30,
19
25
  sortBy: "createdAt",
@@ -22,54 +28,75 @@ describe("listUsersQuery", () => {
22
28
  });
23
29
 
24
30
  it("sorts by a table column on the server", () => {
25
- expect(listUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
31
+ expect(adminUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
26
32
  sortBy: "name",
27
33
  sortDirection: "asc",
28
34
  });
29
- expect(listUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
35
+ expect(adminUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
30
36
  sortBy: "createdAt",
31
37
  sortDirection: "desc",
32
38
  });
33
39
  });
34
40
 
35
- it("searches email case-insensitively and names as typed", () => {
36
- expect(listUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
37
- searchValue: "ada@example.com",
41
+ it("searches the chosen field with the text trimmed", () => {
42
+ expect(adminUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
43
+ search: "Ada@Example.COM",
38
44
  searchField: "email",
39
- searchOperator: "contains",
40
45
  });
41
- expect(listUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
42
- searchValue: "Ada",
46
+ expect(adminUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
47
+ search: "Ada",
43
48
  searchField: "name",
44
49
  });
45
- expect(listUsersQuery({ ...base, search: " " })).not.toHaveProperty("searchValue");
50
+ expect(adminUsersQuery({ ...base, search: " " })).not.toHaveProperty("search");
46
51
  });
47
52
 
48
- it("maps the role and status filters to the single server filter", () => {
49
- expect(listUsersQuery({ ...base, role: "admin" })).toMatchObject({
50
- filterField: "role",
51
- filterValue: "admin",
52
- filterOperator: "eq",
53
+ it("sends the role and status filters together", () => {
54
+ expect(adminUsersQuery({ ...base, role: "admin", status: "active" })).toMatchObject({
55
+ role: "admin",
56
+ status: "active",
53
57
  });
54
- expect(listUsersQuery({ ...base, status: "pending" })).toMatchObject({
55
- filterField: "signupApproved",
56
- filterValue: false,
57
- });
58
- expect(listUsersQuery({ ...base, status: "banned" })).toMatchObject({
59
- filterField: "banned",
60
- filterValue: true,
61
- });
62
- expect(listUsersQuery(base)).not.toHaveProperty("filterField");
58
+ expect(adminUsersQuery({ ...base, status: "pending" })).toMatchObject({ status: "pending" });
59
+ expect(adminUsersQuery({ ...base, status: "deleted" })).not.toHaveProperty("status");
60
+ expect(adminUsersQuery(base)).not.toHaveProperty("role");
61
+ });
62
+ });
63
+
64
+ describe("listAdminUsers", () => {
65
+ it("asks GET /admin/users for one page", async () => {
66
+ const paths: string[] = [];
67
+ const client = {
68
+ get: <T>(path: string): Promise<T> => {
69
+ paths.push(path);
70
+ return Promise.resolve({ items: [], total: 0 } as unknown as T);
71
+ },
72
+ };
73
+
74
+ await listAdminUsers(client, adminUsersQuery({ ...base, page: 2, search: "ada", role: "admin", status: "banned" }));
75
+ await listAdminUsers(client, adminUsersQuery(base));
76
+
77
+ expect(paths).toEqual([
78
+ "/admin/users?limit=15&offset=15&sortBy=createdAt&sortDirection=desc&search=ada&searchField=email&role=admin&status=banned",
79
+ "/admin/users?limit=15&offset=0&sortBy=createdAt&sortDirection=desc",
80
+ ]);
63
81
  });
64
82
  });
65
83
 
66
- describe("keepLatestFilter", () => {
67
- it("keeps whichever filter changed last", () => {
84
+ // Application-owned users pages from 1.2 still import these.
85
+ describe("deprecated listUsers helpers", () => {
86
+ it("still builds the single-filter Better Auth query", () => {
87
+ expect(listUsersQuery({ ...base, page: 2, search: " Ada@Example.COM ", status: "banned" })).toEqual({
88
+ limit: 15,
89
+ offset: 15,
90
+ sortBy: "createdAt",
91
+ sortDirection: "desc",
92
+ searchValue: "ada@example.com",
93
+ searchField: "email",
94
+ searchOperator: "contains",
95
+ filterField: "banned",
96
+ filterValue: true,
97
+ filterOperator: "eq",
98
+ });
68
99
  expect(keepLatestFilter({ role: "admin", status: "" }, { role: "admin", status: "banned" }))
69
100
  .toEqual({ role: "", status: "banned" });
70
- expect(keepLatestFilter({ role: "", status: "banned" }, { role: "user", status: "banned" }))
71
- .toEqual({ role: "user", status: "" });
72
- expect(keepLatestFilter({ role: "", status: "" }, { role: "user", status: "" }))
73
- .toEqual({ role: "user", status: "" });
74
101
  });
75
102
  });
@@ -1,4 +1,6 @@
1
- // Builds the Better Auth `admin.listUsers` query for the server-paged users table.
1
+ import type { ApiClient } from "@podosoft/podokit-api-client";
2
+
3
+ // Builds the `GET /admin/users` query for the server-paged users table.
2
4
 
3
5
  export type UserListSort = { key: string; dir: "asc" | "desc" };
4
6
 
@@ -12,6 +14,82 @@ export type UserListState = {
12
14
  status: string;
13
15
  };
14
16
 
17
+ export type AdminUserStatus = "active" | "pending" | "banned";
18
+
19
+ /** A user from `GET /admin/users`. */
20
+ export type AdminUser = {
21
+ id: string;
22
+ name: string;
23
+ email: string;
24
+ role: string | null;
25
+ emailVerified: boolean;
26
+ signupApproved: boolean;
27
+ banned: boolean;
28
+ banReason: string | null;
29
+ banExpires: string | null;
30
+ createdAt: string;
31
+ };
32
+
33
+ export type AdminUserPage = { items: AdminUser[]; total: number };
34
+
35
+ export type AdminUserQuery = {
36
+ limit: number;
37
+ offset: number;
38
+ sortBy: string;
39
+ sortDirection: "asc" | "desc";
40
+ search?: string;
41
+ searchField?: "email" | "name";
42
+ role?: string;
43
+ status?: AdminUserStatus;
44
+ };
45
+
46
+ export const DEFAULT_USER_SORT: UserListSort = { key: "createdAt", dir: "desc" };
47
+
48
+ const SORTABLE_FIELDS = new Set(["name", "email", "role", "createdAt"]);
49
+ const STATUSES = new Set<string>(["active", "pending", "banned"]);
50
+
51
+ function isStatus(value: string): value is AdminUserStatus {
52
+ return STATUSES.has(value);
53
+ }
54
+
55
+ export function adminUsersQuery(state: UserListState): AdminUserQuery {
56
+ const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
57
+ const search = state.search.trim();
58
+ return {
59
+ limit: state.perPage,
60
+ offset: (Math.max(state.page, 1) - 1) * state.perPage,
61
+ sortBy: sort.key,
62
+ sortDirection: sort.dir,
63
+ // The server matches case-insensitively, so the text is sent as typed.
64
+ ...(search ? { search, searchField: state.searchField === "name" ? "name" as const : "email" as const } : {}),
65
+ ...(state.role ? { role: state.role } : {}),
66
+ ...(isStatus(state.status) ? { status: state.status } : {}),
67
+ };
68
+ }
69
+
70
+ /** Reads one page; the role and status filters combine on the server. */
71
+ export function listAdminUsers(client: Pick<ApiClient, "get">, query: AdminUserQuery): Promise<AdminUserPage> {
72
+ const params = new URLSearchParams({
73
+ limit: String(query.limit),
74
+ offset: String(query.offset),
75
+ sortBy: query.sortBy,
76
+ sortDirection: query.sortDirection,
77
+ });
78
+ if (query.search) {
79
+ params.set("search", query.search);
80
+ params.set("searchField", query.searchField ?? "email");
81
+ }
82
+ if (query.role) params.set("role", query.role);
83
+ if (query.status) params.set("status", query.status);
84
+ return client.get<AdminUserPage>(`/admin/users?${params.toString()}`);
85
+ }
86
+
87
+ // The 1.2 users page is application-owned and still imports the helpers below,
88
+ // which build a Better Auth `admin.listUsers` query. Keep them until the next
89
+ // major release so `podo update` does not break that page; new pages use
90
+ // `adminUsersQuery` and `listAdminUsers`.
91
+
92
+ /** @deprecated Use `AdminUserQuery` with `listAdminUsers`. */
15
93
  export type ListUsersQuery = {
16
94
  limit: number;
17
95
  offset: number;
@@ -25,10 +103,6 @@ export type ListUsersQuery = {
25
103
  filterOperator?: "eq";
26
104
  };
27
105
 
28
- export const DEFAULT_USER_SORT: UserListSort = { key: "createdAt", dir: "desc" };
29
-
30
- const SORTABLE_FIELDS = new Set(["name", "email", "role", "createdAt"]);
31
-
32
106
  function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterField" | "filterValue" | "filterOperator"> {
33
107
  if (status === "pending") return { filterField: "signupApproved", filterValue: false, filterOperator: "eq" };
34
108
  if (status === "banned") return { filterField: "banned", filterValue: true, filterOperator: "eq" };
@@ -36,6 +110,7 @@ function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterFi
36
110
  return {};
37
111
  }
38
112
 
113
+ /** @deprecated Use `adminUsersQuery`; `listUsers` accepts only one filter. */
39
114
  export function listUsersQuery(state: UserListState): ListUsersQuery {
40
115
  const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
41
116
  const searchField = state.searchField === "name" ? "name" : "email";
@@ -47,8 +122,6 @@ export function listUsersQuery(state: UserListState): ListUsersQuery {
47
122
  sortDirection: sort.dir,
48
123
  ...(search
49
124
  ? {
50
- // Better Auth matches with a case-sensitive LIKE on PostgreSQL and stores
51
- // emails in lower case, so lower-casing makes email search case-insensitive.
52
125
  searchValue: searchField === "email" ? search.toLowerCase() : search,
53
126
  searchField,
54
127
  searchOperator: "contains" as const,
@@ -58,10 +131,7 @@ export function listUsersQuery(state: UserListState): ListUsersQuery {
58
131
  };
59
132
  }
60
133
 
61
- /**
62
- * `listUsers` accepts one filter, so the role and status selects are mutually
63
- * exclusive: when both hold a value, the one that just changed wins.
64
- */
134
+ /** @deprecated `GET /admin/users` combines the role and status filters. */
65
135
  export function keepLatestFilter(
66
136
  previous: { role: string; status: string },
67
137
  next: { role: string; status: string },
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Application-owned prefixes for `+server.ts` endpoints that serve files, feeds,
3
+ * or other responses needing neither the session nor the site settings (for
4
+ * example "/docs" when an endpoint serves documentation files). A listed path
5
+ * also covers its subpaths. When an endpoint under a listed prefix answers a
6
+ * request, hooks.server.ts skips its two API lookups and the endpoint sees an
7
+ * anonymous `locals.user`. A `+page` under a listed prefix still gets the
8
+ * session and site settings, so it keeps its sign-in and maintenance checks.
9
+ * PodoKit creates this file once and never overwrites it.
10
+ */
11
+ export const APP_ASSET_PATHS: readonly string[] = [];
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Stands in for the application's own list in the app-owned public-paths.ts.
4
+ vi.mock("./public-paths", () => ({ APP_PUBLIC_PATHS: ["/pricing", "/robots.txt"] }));
5
+
6
+ const { isPublicPath } = await import("./guards");
7
+
8
+ describe("isPublicPath", () => {
9
+ it("keeps the sign-in flow and the landing page public", () => {
10
+ expect(isPublicPath("/")).toBe(true);
11
+ expect(isPublicPath("/login")).toBe(true);
12
+ expect(isPublicPath("/reset-password/token")).toBe(true);
13
+ expect(isPublicPath("/admin")).toBe(false);
14
+ expect(isPublicPath("/account")).toBe(false);
15
+ });
16
+
17
+ it("adds the application's public prefixes and their subpaths", () => {
18
+ expect(isPublicPath("/pricing")).toBe(true);
19
+ expect(isPublicPath("/pricing/teams")).toBe(true);
20
+ expect(isPublicPath("/robots.txt")).toBe(true);
21
+ expect(isPublicPath("/pricingx")).toBe(false);
22
+ });
23
+ });
@@ -1,4 +1,5 @@
1
1
  import { error } from "@sveltejs/kit";
2
+ import { APP_PUBLIC_PATHS } from "./public-paths";
2
3
 
3
4
  /** Roles that may reach the admin console. Kept as a set so custom roles can be
4
5
  * added here (and, later, replaced by a permission check) without touching every
@@ -13,6 +14,7 @@ const PUBLIC_PATHS = [
13
14
  "/verify-email",
14
15
  "/pending-approval",
15
16
  "/maintenance",
17
+ ...APP_PUBLIC_PATHS,
16
18
  // Modules add their public (no-session) page prefixes here.
17
19
  // podokit:begin:public-paths
18
20
  // podokit:end:public-paths
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Application-owned public page prefixes. A listed path also covers its
3
+ * subpaths ("/pricing" covers "/pricing/teams"). These pages render for
4
+ * anonymous visitors and keep rendering when the API or site settings are
5
+ * unavailable; every other page requires a session and fails closed with 503
6
+ * during an outage. Modules add their own prefixes in guards.ts, so do not
7
+ * repeat them here. PodoKit creates this file once and never overwrites it.
8
+ */
9
+ export const APP_PUBLIC_PATHS: readonly string[] = [];
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Stands in for the application's own list in the app-owned asset-paths.ts.
4
+ vi.mock("./asset-paths", () => ({ APP_ASSET_PATHS: ["/docs", "/feed.xml"] }));
5
+
6
+ const { needsRuntimeContext, prefersHtml } = await import("./runtime-context");
7
+
8
+ const NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
9
+ const IMAGE = "image/avif,image/webp,image/apng,*/*;q=0.8";
10
+
11
+ interface RequestOptions {
12
+ routeId?: string | null;
13
+ method?: string;
14
+ headers?: Record<string, string>;
15
+ isDataRequest?: boolean;
16
+ isRemoteRequest?: boolean;
17
+ }
18
+
19
+ function request(path: string, options: RequestOptions = {}) {
20
+ const url = new URL(path, "http://app.localhost");
21
+ return {
22
+ url,
23
+ route: { id: options.routeId === undefined ? url.pathname : options.routeId },
24
+ request: new Request(url, { method: options.method ?? "GET", headers: options.headers }),
25
+ isDataRequest: options.isDataRequest ?? false,
26
+ isRemoteRequest: options.isRemoteRequest ?? false,
27
+ };
28
+ }
29
+
30
+ describe("needsRuntimeContext", () => {
31
+ it("loads the session and site settings for pages and their data", () => {
32
+ expect(needsRuntimeContext(request("/account", { headers: { accept: NAVIGATION } }))).toBe(true);
33
+ expect(needsRuntimeContext(request("/account", { headers: { accept: "*/*" } }))).toBe(true);
34
+ expect(needsRuntimeContext(request("/account", { isDataRequest: true }))).toBe(true);
35
+ });
36
+
37
+ it("loads them for an unmatched route because the 404 page renders the root layout", () => {
38
+ // Without the session a signed-in visitor was sent to /login, which sent
39
+ // them straight back: a redirect loop on every missing page.
40
+ expect(
41
+ needsRuntimeContext(
42
+ request("/missing", { routeId: null, headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
43
+ ),
44
+ ).toBe(true);
45
+ expect(needsRuntimeContext(request("/missing", { routeId: null, headers: { accept: "*/*" } }))).toBe(true);
46
+ expect(needsRuntimeContext(request("/missing", { routeId: null, isDataRequest: true }))).toBe(true);
47
+ });
48
+
49
+ it("skips them for a missing subresource that SvelteKit answers without a layout", () => {
50
+ for (const destination of ["image", "script", "style", "font"]) {
51
+ expect(
52
+ needsRuntimeContext(request("/missing.png", { routeId: null, headers: { "sec-fetch-dest": destination } })),
53
+ ).toBe(false);
54
+ }
55
+ });
56
+
57
+ it("skips them for the backend proxy", () => {
58
+ expect(needsRuntimeContext(request("/api", { routeId: "/api/[...path]" }))).toBe(false);
59
+ expect(needsRuntimeContext(request("/api/site/settings", { routeId: "/api/[...path]" }))).toBe(false);
60
+ expect(needsRuntimeContext(request("/api/auth/get-session", { routeId: "/api/auth/[...all]" }))).toBe(false);
61
+ expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
62
+ });
63
+
64
+ it("skips them while prerendering", () => {
65
+ expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
66
+ });
67
+
68
+ it("loads them for remote functions, which may read the session without a route", () => {
69
+ expect(needsRuntimeContext(request("/_app/remote/abc/list", { routeId: null, isRemoteRequest: true }))).toBe(true);
70
+ });
71
+
72
+ it("skips them when an endpoint under an application asset path answers", () => {
73
+ const files = { page: false, endpoint: true };
74
+ expect(
75
+ needsRuntimeContext(
76
+ request("/docs/guide/diagram.png", { routeId: "/docs/[...path]", headers: { accept: IMAGE, "sec-fetch-dest": "image" } }),
77
+ { route: files },
78
+ ),
79
+ ).toBe(false);
80
+ // Opening a file in a tab is a navigation, but no page renders.
81
+ expect(
82
+ needsRuntimeContext(
83
+ request("/docs/manual.pdf", { routeId: "/docs/[...path]", headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
84
+ { route: files },
85
+ ),
86
+ ).toBe(false);
87
+ expect(needsRuntimeContext(request("/feed.xml", { headers: { accept: "application/rss+xml" } }), { route: files })).toBe(false);
88
+ expect(needsRuntimeContext(request("/documents", { headers: { accept: "*/*" } }), { route: files })).toBe(true);
89
+ });
90
+
91
+ it("keeps them when a page under an application asset path answers", () => {
92
+ const page = { page: true, endpoint: false };
93
+ expect(needsRuntimeContext(request("/docs", { headers: { accept: "*/*" } }), { route: page })).toBe(true);
94
+ expect(needsRuntimeContext(request("/docs", { headers: { accept: NAVIGATION } }), { route: page })).toBe(true);
95
+ });
96
+
97
+ it("follows SvelteKit's page or endpoint choice when an asset route has both", () => {
98
+ const both = { page: true, endpoint: true };
99
+ const docs = (options: RequestOptions) =>
100
+ needsRuntimeContext(request("/docs/intro", { routeId: "/docs/[slug]", ...options }), { route: both });
101
+ expect(docs({ headers: { accept: NAVIGATION } })).toBe(true);
102
+ expect(docs({ isDataRequest: true })).toBe(true);
103
+ expect(docs({ method: "POST", headers: { accept: "application/json", "x-sveltekit-action": "true" } })).toBe(true);
104
+ expect(docs({ headers: { accept: IMAGE } })).toBe(false);
105
+ expect(docs({ headers: { accept: "*/*" } })).toBe(false);
106
+ expect(docs({ method: "PUT", headers: { accept: NAVIGATION } })).toBe(false);
107
+ // Without route information the same negotiation applies.
108
+ expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: IMAGE } }))).toBe(false);
109
+ expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: NAVIGATION } }))).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe("prefersHtml", () => {
114
+ it("follows SvelteKit's page and endpoint negotiation", () => {
115
+ expect(prefersHtml(NAVIGATION)).toBe(true);
116
+ expect(prefersHtml("text/*")).toBe(true);
117
+ expect(prefersHtml("*/*")).toBe(false);
118
+ expect(prefersHtml(null)).toBe(false);
119
+ expect(prefersHtml(IMAGE)).toBe(false);
120
+ expect(prefersHtml("application/json, text/html;q=0.5")).toBe(true);
121
+ expect(prefersHtml("application/json, */*;q=0.9, text/html;q=0.5")).toBe(false);
122
+ expect(prefersHtml("*/*;q=0.9, text/html")).toBe(true);
123
+ expect(prefersHtml("text/html;q=0, */*")).toBe(false);
124
+ });
125
+ });