@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,107 @@
1
+ import { APP_ASSET_PATHS } from "./asset-paths";
2
+
3
+ /** The request fields that decide whether a response can use the session and site settings. */
4
+ export interface RuntimeContextRequest {
5
+ url: URL;
6
+ route: { id: string | null };
7
+ request: Request;
8
+ isDataRequest: boolean;
9
+ isRemoteRequest: boolean;
10
+ }
11
+
12
+ // Fetch destinations that SvelteKit answers with a plain-text 404, without the
13
+ // root layout, when no route matches.
14
+ const SUBRESOURCE_DESTINATIONS = new Set([
15
+ "audio",
16
+ "audioworklet",
17
+ "font",
18
+ "image",
19
+ "json",
20
+ "manifest",
21
+ "paintworklet",
22
+ "report",
23
+ "script",
24
+ "serviceworker",
25
+ "sharedworker",
26
+ "style",
27
+ "track",
28
+ "video",
29
+ "webidentity",
30
+ "worker",
31
+ "xslt",
32
+ ]);
33
+
34
+ function underPrefix(pathname: string, prefix: string): boolean {
35
+ return pathname === prefix || (prefix !== "/" && pathname.startsWith(`${prefix}/`));
36
+ }
37
+
38
+ /**
39
+ * True when an Accept header ranks HTML ahead of the wildcard. This mirrors how
40
+ * SvelteKit chooses between a page and an endpoint on the same route.
41
+ */
42
+ export function prefersHtml(accept: string | null): boolean {
43
+ const ranges = (accept ?? "*/*")
44
+ .split(",")
45
+ .map((part, index) => {
46
+ const [range = "", ...params] = part.trim().toLowerCase().split(";");
47
+ const quality = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
48
+ return { range: range.trim(), q: quality ? Number(quality.slice(2)) : 1, index };
49
+ })
50
+ .filter(({ range, q }) => range.includes("/") && q > 0)
51
+ .sort((a, b) => b.q - a.q || specificity(b.range) - specificity(a.range) || a.index - b.index);
52
+ const html = ranges.findIndex(({ range }) => range === "text/html" || range === "text/*");
53
+ const wildcard = ranges.findIndex(({ range }) => range === "*/*");
54
+ return html !== -1 && (wildcard === -1 || html < wildcard);
55
+ }
56
+
57
+ function specificity(range: string): number {
58
+ if (range === "*/*") return 0;
59
+ return range.endsWith("/*") ? 1 : 2;
60
+ }
61
+
62
+ /** Which files the matched route has, from `routes` in `$app/manifest`. */
63
+ export interface RouteFiles {
64
+ page: boolean;
65
+ endpoint: boolean;
66
+ }
67
+
68
+ /** True when SvelteKit answers the request with the route's `+page` rather than its `+server`. */
69
+ function answeredByPage(event: RuntimeContextRequest, route: RouteFiles | undefined): boolean {
70
+ if (route && !route.page) return false;
71
+ if (route && !route.endpoint) return true;
72
+ if (event.isDataRequest) return true;
73
+ const { headers, method } = event.request;
74
+ if (method !== "GET" && method !== "HEAD" && method !== "POST") return false;
75
+ if (method === "POST" && headers.get("x-sveltekit-action") === "true") return true;
76
+ return prefersHtml(headers.get("accept"));
77
+ }
78
+
79
+ /**
80
+ * Decide whether hooks.server.ts must load the session and the site settings
81
+ * before SvelteKit answers the request. It skips them only when the response
82
+ * cannot render a layout, or when the application listed the path in
83
+ * APP_ASSET_PATHS and a `+server` endpoint, not a `+page`, answers it.
84
+ */
85
+ export function needsRuntimeContext(
86
+ event: RuntimeContextRequest,
87
+ options: { building?: boolean; route?: RouteFiles } = {},
88
+ ): boolean {
89
+ // Prerendering runs at build time without the API, and the stored HTML must
90
+ // not depend on one visitor's session or on runtime site settings.
91
+ if (options.building) return false;
92
+ const { pathname } = event.url;
93
+ // The backend proxy forwards the cookies; the API resolves the session itself.
94
+ if (underPrefix(pathname, "/api")) return false;
95
+ // Remote functions run application code that may read the session.
96
+ if (event.isRemoteRequest) return true;
97
+ if (event.route.id === null) {
98
+ // SvelteKit renders its 404 page with the root layout, which redirects or
99
+ // greets the visitor, unless the browser fetched a subresource.
100
+ const destination = event.request.headers.get("sec-fetch-dest") ?? "";
101
+ return !SUBRESOURCE_DESTINATIONS.has(destination);
102
+ }
103
+ if (APP_ASSET_PATHS.some((prefix) => underPrefix(pathname, prefix))) {
104
+ return answeredByPage(event, options.route);
105
+ }
106
+ return true;
107
+ }
@@ -17,7 +17,7 @@
17
17
  import { toast } from "svelte-sonner";
18
18
  import { api } from "#lib/api.js";
19
19
  import { listAdminSessions, revokeAdminSession, type AdminSession } from "#lib/admin/sessions.js";
20
- import { DEFAULT_USER_SORT, keepLatestFilter, listUsersQuery } from "#lib/admin/users.js";
20
+ import { DEFAULT_USER_SORT, adminUsersQuery, listAdminUsers } from "#lib/admin/users.js";
21
21
  import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
22
22
  import { cn } from "#lib/utils.js";
23
23
  import { deviceLabel } from "#lib/device-label.js";
@@ -64,19 +64,6 @@
64
64
  let appliedSearchField = $state("email");
65
65
  let filterValues = $state<Record<string, string>>({ role: "", status: "" });
66
66
  let appliedFilters = $state<Record<string, string>>({ role: "", status: "" });
67
- let stagedFilters = { role: "", status: "" };
68
-
69
- // The list endpoint takes a single filter, so picking a role clears the status
70
- // and vice versa (see keepLatestFilter).
71
- $effect(() => {
72
- const next = keepLatestFilter(stagedFilters, {
73
- role: filterValues.role ?? "",
74
- status: filterValues.status ?? "",
75
- });
76
- stagedFilters = next;
77
- if (filterValues.role !== next.role) filterValues.role = next.role;
78
- if (filterValues.status !== next.status) filterValues.status = next.status;
79
- });
80
67
 
81
68
  const columns = $derived<DataTableColumn<Row>[]>([
82
69
  { key: "name", label: i18n.t.users.name, sortable: true },
@@ -103,6 +90,7 @@
103
90
  label: i18n.t.users.status,
104
91
  options: [
105
92
  { value: "", label: i18n.t.toolbar.all },
93
+ { value: "active", label: i18n.t.users.active },
106
94
  { value: "pending", label: i18n.t.users.pendingApproval },
107
95
  { value: "banned", label: i18n.t.users.banned },
108
96
  ],
@@ -118,7 +106,7 @@
118
106
  const request = ++latestRequest;
119
107
  const sortKey = sort ? `${sort.key}:${sort.dir}` : "";
120
108
  if (sortKey !== loadedSortKey) page = 1;
121
- const query = listUsersQuery({
109
+ const query = adminUsersQuery({
122
110
  page,
123
111
  perPage: PAGE_SIZE,
124
112
  sort,
@@ -128,12 +116,18 @@
128
116
  status: appliedFilters.status ?? "",
129
117
  });
130
118
  loading = true;
131
- const { data: res, error } = await api.auth.admin.listUsers({ query });
119
+ let result: { items: Row[]; total: number };
120
+ try {
121
+ result = await listAdminUsers(api, query);
122
+ } catch {
123
+ if (request !== latestRequest) return;
124
+ loading = false;
125
+ return void toast.error(i18n.t.users.loadFailed);
126
+ }
132
127
  if (request !== latestRequest) return;
133
128
  loading = false;
134
- if (error) return void toast.error(i18n.t.users.loadFailed);
135
- const rows = (res?.users ?? []) as Row[];
136
- const count = res?.total ?? rows.length;
129
+ const rows = result.items;
130
+ const count = result.total;
137
131
  // An action can empty the last page; show the one before it instead.
138
132
  const lastPage = Math.max(Math.ceil(count / PAGE_SIZE), 1);
139
133
  if (rows.length === 0 && page > lastPage) {
@@ -0,0 +1,86 @@
1
+ import type { APIRequestContext, PlaywrightWorkerArgs } from "@playwright/test";
2
+ import { expect, test } from "../helpers/disposable-users";
3
+ import { ADMIN, USER } from "../helpers/accounts";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+ const origin = { origin: base };
7
+
8
+ type UserItem = {
9
+ id: string;
10
+ email: string;
11
+ role: string | null;
12
+ banned: boolean;
13
+ signupApproved: boolean;
14
+ [key: string]: unknown;
15
+ };
16
+ type UserPage = { items: UserItem[]; total: number };
17
+
18
+ async function signedIn(
19
+ playwright: PlaywrightWorkerArgs["playwright"],
20
+ account: { email: string; password: string },
21
+ ): Promise<APIRequestContext> {
22
+ const context = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
23
+ expect((await context.post("/api/auth/sign-in/email", { data: account })).ok()).toBeTruthy();
24
+ return context;
25
+ }
26
+
27
+ async function list(admin: APIRequestContext, query: Record<string, string>): Promise<UserPage> {
28
+ const response = await admin.get(`/api/admin/users?${new URLSearchParams(query).toString()}`);
29
+ expect(response.status(), await response.text()).toBe(200);
30
+ return (await response.json()) as UserPage;
31
+ }
32
+
33
+ test("the admin user list combines role and status filters with a search", async ({
34
+ playwright,
35
+ disposableUsers,
36
+ }) => {
37
+ const prefix = `users-filter-${Date.now()}`;
38
+ const ids = {
39
+ activeAdmin: await disposableUsers.create({ email: `${prefix}-a@example.com`, role: "admin" }),
40
+ bannedAdmin: await disposableUsers.create({ email: `${prefix}-b@example.com`, role: "admin" }),
41
+ activeUser: await disposableUsers.create({ email: `${prefix}-c@example.com` }),
42
+ pendingUser: await disposableUsers.create({ email: `${prefix}-d@example.com` }),
43
+ };
44
+ const admin = await signedIn(playwright, ADMIN);
45
+ try {
46
+ expect((await admin.post("/api/auth/admin/ban-user", { data: { userId: ids.bannedAdmin } })).ok())
47
+ .toBeTruthy();
48
+ expect((await admin.post("/api/auth/admin/update-user", {
49
+ data: { userId: ids.pendingUser, data: { signupApproved: false } },
50
+ })).ok()).toBeTruthy();
51
+
52
+ const matching = async (role: string, status: string): Promise<string[]> =>
53
+ (await list(admin, { search: prefix.toUpperCase(), role, status })).items.map((user) => user.id);
54
+ expect(await matching("admin", "active")).toEqual([ids.activeAdmin]);
55
+ expect(await matching("admin", "banned")).toEqual([ids.bannedAdmin]);
56
+ expect(await matching("user", "pending")).toEqual([ids.pendingUser]);
57
+ expect(await matching("user", "active")).toEqual([ids.activeUser]);
58
+
59
+ const all = await list(admin, { search: prefix, limit: "2", sortBy: "email", sortDirection: "asc" });
60
+ expect(all.total).toBe(4);
61
+ expect(all.items.map((user) => user.id)).toEqual([ids.activeAdmin, ids.bannedAdmin]);
62
+ const banned = all.items[1];
63
+ expect(banned?.banned).toBe(true);
64
+ expect(banned?.signupApproved).toBe(true);
65
+ expect(banned).not.toHaveProperty("password");
66
+ } finally {
67
+ await admin.dispose();
68
+ }
69
+ });
70
+
71
+ test("the admin user list is for administrators and validates its query", async ({ playwright }) => {
72
+ const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
73
+ const user = await signedIn(playwright, USER);
74
+ const admin = await signedIn(playwright, ADMIN);
75
+ try {
76
+ expect((await anonymous.get("/api/admin/users")).status()).toBe(401);
77
+ expect((await user.get("/api/admin/users")).status()).toBe(403);
78
+ for (const query of ["status=deleted", "sortBy=password", "limit=101"]) {
79
+ expect((await admin.get(`/api/admin/users?${query}`)).status(), query).toBe(400);
80
+ }
81
+ } finally {
82
+ await anonymous.dispose();
83
+ await user.dispose();
84
+ await admin.dispose();
85
+ }
86
+ });
@@ -31,6 +31,7 @@ test("admin can enable Google OAuth from the DB and it applies without a restart
31
31
  });
32
32
  expect(put.ok()).toBeTruthy();
33
33
  const view = await put.json();
34
+ expect(view.social.google.enabled).toBe(true);
34
35
  expect(view.social.google.hasSecret).toBe(true);
35
36
  expect(view.social.google.clientId).toBe("dummy-google-client-id");
36
37
  expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
@@ -291,7 +291,7 @@ test("a user can create, list and revoke a personal API key", async ({ playwrigh
291
291
  const body = (await (await ctx.get("/api/auth/api-key/list")).json()) as { apiKeys: Array<{ id: string; name: string | null }> };
292
292
  expect(body.apiKeys.some((k) => k.name === "ci")).toBeTruthy();
293
293
 
294
- const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0].id } });
294
+ const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0]?.id } });
295
295
  expect(del.ok()).toBeTruthy();
296
296
  await ctx.dispose();
297
297
  });
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
+ import { expectObjectLength } from "../helpers/object-response";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
  const origin = { origin: base };
@@ -48,7 +49,7 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
48
49
  expect(firstImage.ok()).toBeTruthy();
49
50
  expect(firstImage.headers()["content-type"]).toContain("image/png");
50
51
  expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
51
- expect(firstImage.headers()["content-length"]).toBe(String(PNG.byteLength));
52
+ expectObjectLength(firstImage.headers(), PNG.byteLength);
52
53
  expect((await firstImage.body()).equals(PNG)).toBe(true);
53
54
  const etag = firstImage.headers()["etag"];
54
55
  expect(etag).toMatch(/^"[^"]+"$/);
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
+ import { expectObjectLength } from "../helpers/object-response";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
  const origin = { origin: base };
@@ -101,7 +102,7 @@ test("site favicon: the versioned URL is cached for good and other URLs revalida
101
102
  expect(current.status()).toBe(200);
102
103
  expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
103
104
  expect(current.headers()["content-type"]).toContain("image/png");
104
- expect(current.headers()["content-length"]).toBe(String(ICON.byteLength));
105
+ expectObjectLength(current.headers(), ICON.byteLength);
105
106
  expect((await current.body()).equals(ICON)).toBe(true);
106
107
  const etag = current.headers()["etag"] ?? "";
107
108
  expect(etag).toMatch(/^"[^"]+"$/);
@@ -15,14 +15,17 @@ type CreateUserInput = {
15
15
  role?: string;
16
16
  };
17
17
 
18
+ // UI specs pass the browser's create-user response; API specs pass a request response.
19
+ type CreatedUserResponse = Pick<APIResponse, "ok" | "json">;
20
+
18
21
  export type DisposableUsers = {
19
22
  create(input: CreateUserInput): Promise<string>;
20
- trackResponse(response: APIResponse): Promise<string>;
23
+ trackResponse(response: CreatedUserResponse): Promise<string>;
21
24
  track(userId: string): void;
22
25
  forget(userId: string): void;
23
26
  };
24
27
 
25
- async function readCreatedUserId(response: APIResponse): Promise<string> {
28
+ async function readCreatedUserId(response: CreatedUserResponse): Promise<string> {
26
29
  expect(response.ok()).toBeTruthy();
27
30
  const body = (await response.json()) as { user?: { id?: unknown } };
28
31
  expect(typeof body.user?.id).toBe("string");
@@ -32,16 +35,16 @@ async function readCreatedUserId(response: APIResponse): Promise<string> {
32
35
  export const test = base.extend<{ disposableUsers: DisposableUsers }>({
33
36
  disposableUsers: async ({ context, playwright }, use, testInfo) => {
34
37
  // API projects do not inherit the UI project's admin storageState, while a
35
- // second admin sign-in can invalidate the shared UI session. Authenticate an
36
- // isolated request context only for API tests and reuse browser cookies for UI.
37
- let request = context.request;
38
- let ownsRequest = false;
38
+ // second admin sign-in can invalidate the shared UI session. API tests sign in
39
+ // an isolated request context. UI tests copy the browser's admin cookies into
40
+ // their own context once, so a test that later signs its page in as another
41
+ // user does not turn cleanup into that user's (forbidden) requests.
42
+ const request = await playwright.request.newContext({
43
+ baseURL,
44
+ extraHTTPHeaders: origin,
45
+ ...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
46
+ });
39
47
  if (testInfo.project.name === "api") {
40
- request = await playwright.request.newContext({
41
- baseURL,
42
- extraHTTPHeaders: origin,
43
- });
44
- ownsRequest = true;
45
48
  const signIn = await request.post("/api/auth/sign-in/email", {
46
49
  data: { email: ADMIN.email, password: ADMIN.password },
47
50
  });
@@ -91,7 +94,7 @@ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
91
94
  }
92
95
  }
93
96
  } finally {
94
- if (ownsRequest) await request.dispose();
97
+ await request.dispose();
95
98
  }
96
99
  }
97
100
  },
@@ -0,0 +1,12 @@
1
+ import { expect } from "@playwright/test";
2
+
3
+ // IN_MEMORY_READ_BYTES of @podosoft/podokit-runtime. Both object storage
4
+ // providers and the web proxy keep the Content-Length of objects up to this
5
+ // size; Bun may send larger ones chunked.
6
+ const IN_MEMORY_READ_BYTES = 2 * 1024 * 1024;
7
+
8
+ export function expectObjectLength(headers: Record<string, string>, size: number): void {
9
+ const length = headers["content-length"];
10
+ if (length === undefined && size > IN_MEMORY_READ_BYTES) return;
11
+ expect(length).toBe(String(size));
12
+ }
@@ -2,7 +2,7 @@ import { expect, test, type Response } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // Sorting via the shared DataTable. Users sorts on the server (manual mode
5
- // refetches through Better Auth listUsers), and the audit log asks the server
5
+ // refetches GET /admin/users), and the audit log asks the server
6
6
  // for the other time order — both drive the same clickable headers + aria-sort.
7
7
  test("users table sorts by a column header (server)", async ({ page }) => {
8
8
  await ready(page, "/admin/users");
@@ -13,7 +13,7 @@ test("users table sorts by a column header (server)", async ({ page }) => {
13
13
  const firstName = (): Promise<string> => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
14
14
  const sortedBy = (direction: string): Promise<Response> => page.waitForResponse((response) => {
15
15
  const url = new URL(response.url());
16
- return url.pathname === "/api/auth/admin/list-users" &&
16
+ return url.pathname === "/api/admin/users" &&
17
17
  url.searchParams.get("sortBy") === "name" &&
18
18
  url.searchParams.get("sortDirection") === direction;
19
19
  });
@@ -3,7 +3,6 @@ import { ready } from "../helpers/hydration";
3
3
  import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
4
4
 
5
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
- const origin = { origin: base };
7
6
 
8
7
  // Verification badges/actions are gated by the emailVerification capability. The
9
8
  // seeded suite runs with verification off, so assert the gating here (nothing
@@ -28,9 +28,7 @@ test("forgot password: request, open the emailed link, set a new password, sign
28
28
  await expect(page.getByText(/reset link is on its way/i)).toBeVisible();
29
29
 
30
30
  // Open the emailed link: the API validates it and redirects to the reset page.
31
- await page.goto(await waitForLink(email));
32
- await page.waitForLoadState("load");
33
- await page.waitForTimeout(400);
31
+ await ready(page, await waitForLink(email));
34
32
  // `exact` because "Confirm new password" also contains "New password".
35
33
  await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
36
34
  // A mismatch is refused, and the single-use token survives it: the same page
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { ready } from "../helpers/hydration";
3
4
 
4
5
  // admin storageState (project default) — the admin is the resource owner.
5
6
  const b64url = (b: Buffer): string => b.toString("base64url");
@@ -64,7 +65,8 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
64
65
  // The consent page renders (reached in a real browser navigation), and allowing
65
66
  // it sends the browser back to the client with an authorization code.
66
67
  await page.route(`${REDIRECT}**`, (route) => route.fulfill({ status: 200, body: "callback" }));
67
- await page.goto(consentUrl);
68
+ // Wait for hydration: a click on the server-rendered button sends no consent.
69
+ await ready(page, consentUrl);
68
70
  const allow = page.getByRole("button", { name: "Allow" });
69
71
  await expect(allow).toBeVisible();
70
72
  const callback = page.waitForURL((url) => url.href.startsWith(REDIRECT));
@@ -21,26 +21,25 @@ test("client navigation keeps the server checks without resending messages", asy
21
21
  await ready(page, "/admin");
22
22
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
23
23
 
24
- const bodies: Promise<string>[] = [];
25
- page.on("response", (response) => {
26
- if (new URL(response.url()).pathname.endsWith("/__data.json")) bodies.push(response.text());
27
- });
28
24
  const sidebar = page.locator('[data-sidebar="sidebar"]');
29
- const stops: Array<[link: string, heading: string]> = [
30
- ["Users", "Users"],
31
- ["Sessions", "User sessions"],
32
- ["Settings", "Settings"],
33
- ["Overview", "Dashboard"],
25
+ const stops: Array<[link: string, path: string, heading: string]> = [
26
+ ["Users", "/admin/users", "Users"],
27
+ ["Sessions", "/admin/sessions", "User sessions"],
28
+ ["Settings", "/admin/settings", "Settings"],
29
+ ["Overview", "/admin", "Dashboard"],
34
30
  ];
35
- for (const [link, heading] of stops) {
31
+ for (const [link, path, heading] of stops) {
32
+ // Read each body before the next click: once the page navigates on, the
33
+ // previous response body is no longer available.
34
+ const data = page.waitForResponse(
35
+ (response) => new URL(response.url()).pathname === `${path}/__data.json`,
36
+ );
36
37
  await sidebar.getByRole("link", { name: link, exact: true }).click();
37
- await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
38
- }
39
-
40
- expect(bodies.length).toBeGreaterThanOrEqual(stops.length);
41
- for (const body of await Promise.all(bodies)) {
38
+ const body = await (await data).text();
42
39
  expect(body).not.toContain('"messages"');
43
40
  expect(body.length).toBeLessThan(20_000);
41
+ await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
44
42
  }
43
+
45
44
  await expect(sidebar.getByRole("link", { name: "Back to home" })).toBeVisible();
46
45
  });
@@ -20,31 +20,36 @@ test("users toolbar applies filter + search together on Search", async ({ page }
20
20
  // Selecting a filter stages it; nothing applies until Search is pressed.
21
21
  const filtered = page.waitForResponse((response) => {
22
22
  const url = new URL(response.url());
23
- return url.pathname === "/api/auth/admin/list-users" && url.searchParams.get("filterField") === "role";
23
+ return url.pathname === "/api/admin/users" && url.searchParams.get("role") === "admin";
24
24
  });
25
25
  await page.getByRole("button", { name: "Search", exact: true }).click();
26
- expect(new URL((await filtered).url()).searchParams.get("filterValue")).toBe("admin");
26
+ expect((await filtered).ok()).toBeTruthy();
27
27
  await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
28
28
  await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
29
29
  });
30
30
 
31
- test("users toolbar keeps one filter because the server accepts one", async ({ page }) => {
31
+ test("users toolbar combines the role and status filters", async ({ page }) => {
32
32
  await ready(page, "/admin/users");
33
+ const main = page.getByRole("main");
33
34
  const roleSelect = page.locator('button[data-slot="select-trigger"]').first();
34
35
  const statusSelect = page.locator('button[data-slot="select-trigger"]').nth(1);
35
36
  await roleSelect.click();
36
37
  await page.getByRole("option", { name: "Admin", exact: true }).click();
37
38
  await statusSelect.click();
38
- await page.getByRole("option", { name: "Banned", exact: true }).click();
39
- await expect(roleSelect).toContainText("All");
40
- await expect(statusSelect).toContainText("Banned");
39
+ await page.getByRole("option", { name: "Active", exact: true }).click();
40
+ await expect(roleSelect).toContainText("Admin");
41
+ await expect(statusSelect).toContainText("Active");
41
42
 
42
43
  const filtered = page.waitForResponse((response) => {
43
44
  const url = new URL(response.url());
44
- return url.pathname === "/api/auth/admin/list-users" && url.searchParams.get("filterField") === "banned";
45
+ return url.pathname === "/api/admin/users" &&
46
+ url.searchParams.get("role") === "admin" &&
47
+ url.searchParams.get("status") === "active";
45
48
  });
46
49
  await page.getByRole("button", { name: "Search", exact: true }).click();
47
- expect(new URL((await filtered).url()).searchParams.get("filterValue")).toBe("true");
50
+ expect((await filtered).ok()).toBeTruthy();
51
+ await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
52
+ await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
48
53
  });
49
54
 
50
55
  test("users toolbar can pick the search field", async ({ page }) => {
@@ -140,7 +140,7 @@ test("users list shows pagination beyond one page", async ({ page, disposableUse
140
140
  await expect(nav).toBeVisible();
141
141
  const secondPage = page.waitForResponse((response) => {
142
142
  const url = new URL(response.url());
143
- return url.pathname === "/api/auth/admin/list-users" && url.searchParams.get("offset") === "15";
143
+ return url.pathname === "/api/admin/users" && url.searchParams.get("offset") === "15";
144
144
  });
145
145
  await nav.getByRole("button", { name: "Page 2", exact: true }).first().click();
146
146
  expect((await secondPage).ok()).toBeTruthy();
@@ -44,7 +44,9 @@ test("admin sees the user list and can search", async ({ page }) => {
44
44
  });
45
45
 
46
46
  test("the user list asks the server for one page at a time", async ({ page }) => {
47
- const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/auth/admin/list-users";
47
+ const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/admin/users";
48
+ const requested: string[] = [];
49
+ page.on("request", (request) => requested.push(request.url()));
48
50
  const first = page.waitForResponse((response) => isListUsers(response.url()));
49
51
  await ready(page, "/admin/users");
50
52
  const initial = new URL((await first).url()).searchParams;
@@ -53,14 +55,16 @@ test("the user list asks the server for one page at a time", async ({ page }) =>
53
55
  expect(initial.get("sortDirection")).toBe("desc");
54
56
  await expect(page.getByRole("columnheader", { name: "Joined" })).toHaveAttribute("aria-sort", "descending");
55
57
 
58
+ // The server matches case-insensitively, so the search goes as typed.
56
59
  const searched = page.waitForResponse((response) => isListUsers(response.url()));
57
60
  await page.locator("#toolbar-search").fill("Admin@Example.com");
58
61
  await page.getByRole("button", { name: "Search", exact: true }).click();
59
62
  const query = new URL((await searched).url()).searchParams;
60
- expect(query.get("searchValue")).toBe("admin@example.com");
63
+ expect(query.get("search")).toBe("Admin@Example.com");
61
64
  expect(query.get("searchField")).toBe("email");
62
65
  expect(query.get("offset")).toBe("0");
63
66
  await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
67
+ expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
64
68
  });
65
69
 
66
70
  test("user list shows the joined column", async ({ page }) => {
@@ -69,9 +73,16 @@ test("user list shows the joined column", async ({ page }) => {
69
73
  });
70
74
 
71
75
  test("row menu exposes admin actions", async ({ page }) => {
72
- await page.goto("/admin/users");
76
+ // The list shows one server page, so the search must run before the row exists.
77
+ await ready(page, "/admin/users");
78
+ const searched = page.waitForResponse((response) => {
79
+ const url = new URL(response.url());
80
+ return url.pathname === "/api/admin/users" &&
81
+ url.searchParams.get("search") === "user@example.com";
82
+ });
73
83
  await page.locator("#toolbar-search").fill("user@example.com");
74
84
  await page.getByRole("button", { name: "Search", exact: true }).click();
85
+ expect((await searched).ok()).toBeTruthy();
75
86
  const row = page.getByRole("row", { name: /user@example.com/ });
76
87
  await row.getByRole("button").click();
77
88
  await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "ownedGlobs": [
15
15
  "apps/web/src/lib/i18n/locales/**",
16
- "apps/web/src/lib/i18n/catalogs/app/**"
16
+ "apps/web/src/lib/i18n/catalogs/app/**",
17
+ "apps/web/src/lib/server/public-paths.ts",
18
+ "apps/web/src/lib/server/asset-paths.ts"
17
19
  ],
18
20
  "managedOverrides": [
19
21
  "apps/web/src/lib/components/ui/input/input.svelte",
@@ -117,7 +119,7 @@
117
119
  "file": "AGENTS.md",
118
120
  "marker": "<!-- podokit:end:agents-modules -->",
119
121
  "optional": true,
120
- "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; 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- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` 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. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
122
+ "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; 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- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\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. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
121
123
  },
122
124
  {
123
125
  "file": "apps/api/src/app.ts",