@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -21,28 +21,36 @@ export const handle: Handle = async ({ event, resolve }) => {
21
21
  return applySearchIndexingHeaders(response, event.url.pathname);
22
22
  }
23
23
 
24
- // Public site settings (branding, locale/timezone defaults, maintenance, …),
25
- // loaded once here and reused by the layout load. A visitor with no locale
26
- // cookie falls back to the admin-configured site locale, then the app default.
24
+ // Public site settings (branding, locale/timezone defaults, maintenance, …)
25
+ // and the session are independent, so both requests run at once. The
26
+ // settings are loaded once here and reused by the layout load. The async
27
+ // wrappers turn a synchronous client failure into a settled rejection.
28
+ const api = serverApiClient(event);
29
+ const [siteResult, sessionResult] = await Promise.allSettled([
30
+ (async () => api.get<SiteSettings>("/site/settings"))(),
31
+ (async () => api.auth.getSession())(),
32
+ ]);
33
+
27
34
  event.locals.siteUnavailable = false;
28
35
  let site: SiteSettings | null = null;
29
- try {
30
- site = await serverApiClient(event).get<SiteSettings>("/site/settings");
31
- } catch (cause: unknown) {
32
- site = null;
36
+ if (siteResult.status === "fulfilled") {
37
+ site = siteResult.value;
38
+ } else {
33
39
  // A request-level 4xx proves that the backend is reachable. In particular,
34
40
  // the require-2FA guard returns 403 until enrolment and must remain able to
35
41
  // redirect the user to /setup-2fa instead of being masked as an outage.
36
- event.locals.siteUnavailable = isTemporaryServiceFailure(cause);
42
+ event.locals.siteUnavailable = isTemporaryServiceFailure(siteResult.reason);
37
43
  }
38
44
  event.locals.site = site;
45
+ // A visitor with no locale cookie falls back to the admin-configured site
46
+ // locale, then the app default.
39
47
  event.locals.locale = resolveLocale(event.cookies.get("locale") ?? site?.locale ?? undefined);
40
48
 
41
49
  event.locals.user = null;
42
50
  event.locals.session = null;
43
51
  event.locals.authUnavailable = false;
44
- try {
45
- const { data, error } = await serverApiClient(event).auth.getSession();
52
+ if (sessionResult.status === "fulfilled") {
53
+ const { data, error } = sessionResult.value;
46
54
  // Better Auth returns HTTP authentication failures as data/error. A 401 is
47
55
  // a confirmed anonymous session; every other error means that the session
48
56
  // could not be checked and must not be treated as a logout.
@@ -54,7 +62,7 @@ export const handle: Handle = async ({ event, resolve }) => {
54
62
  ? { id: data.session.id, impersonatedBy: (data.session as { impersonatedBy?: string | null }).impersonatedBy ?? null }
55
63
  : null;
56
64
  }
57
- } catch {
65
+ } else {
58
66
  // Transport failures do not invalidate the browser's session cookie. A
59
67
  // protected loader will fail closed with 503 until the backend recovers.
60
68
  event.locals.authUnavailable = true;
@@ -1,43 +1,17 @@
1
1
  import type { Capabilities } from "@podosoft/podokit-api-client";
2
+ import { getCapabilities, type CapabilitiesEvent } from "#lib/server/capabilities.js";
2
3
 
3
- type ServerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
4
-
5
- const defaultCapabilities: Capabilities = {
6
- twoFactor: false,
7
- providers: [],
8
- deleteAccount: false,
9
- auditLog: false,
10
- emailVerification: false,
11
- signupApprovalRequired: false,
12
- passwordBreachCheck: false,
13
- magicLink: false,
14
- emailOtp: false,
15
- username: false,
16
- multiSession: false,
17
- phoneNumber: false,
18
- apiKey: false,
19
- passkey: false,
20
- organization: false,
21
- oidcProvider: false,
22
- roles: ["admin", "user"],
23
- };
24
-
25
- export async function loadAccountData(locals: App.Locals, fetch: ServerFetch): Promise<{
4
+ export async function loadAccountData(
5
+ locals: App.Locals,
6
+ fetch: CapabilitiesEvent["fetch"],
7
+ ): Promise<{
26
8
  user: App.Locals["user"];
27
9
  currentSessionId: string | null;
28
10
  capabilities: Capabilities;
29
11
  }> {
30
- let capabilities = defaultCapabilities;
31
- try {
32
- const response = await fetch("/api/account/capabilities");
33
- if (response.ok) capabilities = (await response.json()) as Capabilities;
34
- } catch {
35
- /* Keep safe defaults if the capability endpoint is unavailable. */
36
- }
37
-
38
12
  return {
39
13
  user: locals.user,
40
14
  currentSessionId: locals.session?.id ?? null,
41
- capabilities,
15
+ capabilities: await getCapabilities({ locals, fetch }),
42
16
  };
43
17
  }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { KeysetPages, listAdminSessions, revokeAdminSession } from "./sessions";
3
+
4
+ describe("listAdminSessions", () => {
5
+ it("asks for one page with the cursor, trimmed search, and user", async () => {
6
+ const paths: string[] = [];
7
+ const client = {
8
+ get: <T>(path: string): Promise<T> => {
9
+ paths.push(path);
10
+ return Promise.resolve({ items: [], nextCursor: null, total: 0 } as unknown as T);
11
+ },
12
+ };
13
+
14
+ await listAdminSessions(client, { limit: 15, cursor: "abc", search: " ada ", userId: "user-1" });
15
+ await listAdminSessions(client, { limit: 15, cursor: null, search: " " });
16
+
17
+ expect(paths).toEqual([
18
+ "/admin/sessions?limit=15&cursor=abc&search=ada&userId=user-1",
19
+ "/admin/sessions?limit=15",
20
+ ]);
21
+ });
22
+ });
23
+
24
+ describe("revokeAdminSession", () => {
25
+ it("revokes by id", async () => {
26
+ const paths: string[] = [];
27
+ const client = {
28
+ del: <T>(path: string): Promise<T> => {
29
+ paths.push(path);
30
+ return Promise.resolve(undefined as unknown as T);
31
+ },
32
+ };
33
+
34
+ await revokeAdminSession(client, "id/with slash");
35
+
36
+ expect(paths).toEqual(["/admin/sessions/id%2Fwith%20slash"]);
37
+ });
38
+ });
39
+
40
+ describe("KeysetPages", () => {
41
+ it("offers the visited pages plus the next one", () => {
42
+ const pages = new KeysetPages();
43
+ expect(pages.cursorFor(1)).toBeNull();
44
+ expect(pages.cursorFor(2)).toBeUndefined();
45
+ expect(pages.pagerTotal(100, 15)).toBe(15);
46
+
47
+ pages.record(1, "c2");
48
+ pages.record(2, "c3");
49
+ expect(pages.cursorFor(2)).toBe("c2");
50
+ expect(pages.cursorFor(3)).toBe("c3");
51
+ expect(pages.pagerTotal(100, 15)).toBe(45);
52
+ });
53
+
54
+ it("stops at the last page and never exceeds the total", () => {
55
+ const pages = new KeysetPages();
56
+ pages.record(1, "c2");
57
+ pages.record(2, null);
58
+ expect(pages.cursorFor(3)).toBeUndefined();
59
+ expect(pages.pagerTotal(20, 15)).toBe(20);
60
+ expect(pages.pagerTotal(0, 15)).toBe(0);
61
+ });
62
+
63
+ it("drops later cursors when a page is reloaded", () => {
64
+ const pages = new KeysetPages();
65
+ pages.record(1, "c2");
66
+ pages.record(2, "c3");
67
+ pages.record(1, "c2-new");
68
+ expect(pages.cursorFor(2)).toBe("c2-new");
69
+ expect(pages.cursorFor(3)).toBeUndefined();
70
+
71
+ pages.reset();
72
+ expect(pages.cursorFor(2)).toBeUndefined();
73
+ });
74
+ });
@@ -0,0 +1,73 @@
1
+ import type { ApiClient } from "@podosoft/podokit-api-client";
2
+
3
+ /** An active session from `GET /admin/sessions`. The API never returns the session token. */
4
+ export type AdminSession = {
5
+ id: string;
6
+ userId: string;
7
+ userEmail: string;
8
+ userName: string;
9
+ ipAddress: string | null;
10
+ userAgent: string | null;
11
+ impersonatedBy: string | null;
12
+ createdAt: string;
13
+ expiresAt: string;
14
+ };
15
+
16
+ export type AdminSessionPage = {
17
+ items: AdminSession[];
18
+ nextCursor: string | null;
19
+ total: number;
20
+ };
21
+
22
+ export type AdminSessionQuery = {
23
+ limit: number;
24
+ cursor?: string | null;
25
+ search?: string;
26
+ userId?: string;
27
+ };
28
+
29
+ export function listAdminSessions(
30
+ client: Pick<ApiClient, "get">,
31
+ query: AdminSessionQuery,
32
+ ): Promise<AdminSessionPage> {
33
+ const params = new URLSearchParams({ limit: String(query.limit) });
34
+ if (query.cursor) params.set("cursor", query.cursor);
35
+ const search = query.search?.trim();
36
+ if (search) params.set("search", search);
37
+ if (query.userId) params.set("userId", query.userId);
38
+ return client.get<AdminSessionPage>(`/admin/sessions?${params.toString()}`);
39
+ }
40
+
41
+ /** Revokes by session id; the server looks up the token, so it never reaches the browser. */
42
+ export function revokeAdminSession(client: Pick<ApiClient, "del">, id: string): Promise<void> {
43
+ return client.del<void>(`/admin/sessions/${encodeURIComponent(id)}`);
44
+ }
45
+
46
+ /**
47
+ * Remembers the keyset cursor that starts each page reached so far. The API pages
48
+ * forward only, so the DataTable pager offers the pages already visited plus the
49
+ * next one, while the footer label still shows the full total.
50
+ */
51
+ export class KeysetPages {
52
+ #cursors: Array<string | null> = [null];
53
+
54
+ reset(): void {
55
+ this.#cursors = [null];
56
+ }
57
+
58
+ /** The cursor that starts `page` (1-based): `null` for the first page, `undefined` when not reached yet. */
59
+ cursorFor(page: number): string | null | undefined {
60
+ return this.#cursors[page - 1];
61
+ }
62
+
63
+ /** Records the result of loading `page`; cursors after it are stale once the page is reloaded. */
64
+ record(page: number, nextCursor: string | null): void {
65
+ this.#cursors.length = page;
66
+ if (nextCursor) this.#cursors.push(nextCursor);
67
+ }
68
+
69
+ /** The row count to give the DataTable pager so it shows exactly the reachable pages. */
70
+ pagerTotal(total: number, perPage: number): number {
71
+ return Math.min(total, this.#cursors.length * perPage);
72
+ }
73
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ adminUsersQuery,
4
+ keepLatestFilter,
5
+ listAdminUsers,
6
+ listUsersQuery,
7
+ type UserListState,
8
+ } from "./users";
9
+
10
+ const base: UserListState = {
11
+ page: 1,
12
+ perPage: 15,
13
+ sort: null,
14
+ search: "",
15
+ searchField: "email",
16
+ role: "",
17
+ status: "",
18
+ };
19
+
20
+ describe("adminUsersQuery", () => {
21
+ it("pages newest first by default", () => {
22
+ expect(adminUsersQuery({ ...base, page: 3 })).toEqual({
23
+ limit: 15,
24
+ offset: 30,
25
+ sortBy: "createdAt",
26
+ sortDirection: "desc",
27
+ });
28
+ });
29
+
30
+ it("sorts by a table column on the server", () => {
31
+ expect(adminUsersQuery({ ...base, sort: { key: "name", dir: "asc" } })).toMatchObject({
32
+ sortBy: "name",
33
+ sortDirection: "asc",
34
+ });
35
+ expect(adminUsersQuery({ ...base, sort: { key: "status", dir: "asc" } })).toMatchObject({
36
+ sortBy: "createdAt",
37
+ sortDirection: "desc",
38
+ });
39
+ });
40
+
41
+ it("searches the chosen field with the text trimmed", () => {
42
+ expect(adminUsersQuery({ ...base, search: " Ada@Example.COM " })).toMatchObject({
43
+ search: "Ada@Example.COM",
44
+ searchField: "email",
45
+ });
46
+ expect(adminUsersQuery({ ...base, search: "Ada", searchField: "name" })).toMatchObject({
47
+ search: "Ada",
48
+ searchField: "name",
49
+ });
50
+ expect(adminUsersQuery({ ...base, search: " " })).not.toHaveProperty("search");
51
+ });
52
+
53
+ it("sends the role and status filters together", () => {
54
+ expect(adminUsersQuery({ ...base, role: "admin", status: "active" })).toMatchObject({
55
+ role: "admin",
56
+ status: "active",
57
+ });
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
+ ]);
81
+ });
82
+ });
83
+
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
+ });
99
+ expect(keepLatestFilter({ role: "admin", status: "" }, { role: "admin", status: "banned" }))
100
+ .toEqual({ role: "", status: "banned" });
101
+ });
102
+ });
@@ -0,0 +1,141 @@
1
+ import type { ApiClient } from "@podosoft/podokit-api-client";
2
+
3
+ // Builds the `GET /admin/users` query for the server-paged users table.
4
+
5
+ export type UserListSort = { key: string; dir: "asc" | "desc" };
6
+
7
+ export type UserListState = {
8
+ page: number;
9
+ perPage: number;
10
+ sort: UserListSort | null;
11
+ search: string;
12
+ searchField: string;
13
+ role: string;
14
+ status: string;
15
+ };
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`. */
93
+ export type ListUsersQuery = {
94
+ limit: number;
95
+ offset: number;
96
+ sortBy: string;
97
+ sortDirection: "asc" | "desc";
98
+ searchValue?: string;
99
+ searchField?: "email" | "name";
100
+ searchOperator?: "contains";
101
+ filterField?: "role" | "banned" | "signupApproved";
102
+ filterValue?: string | boolean;
103
+ filterOperator?: "eq";
104
+ };
105
+
106
+ function filterFor(role: string, status: string): Pick<ListUsersQuery, "filterField" | "filterValue" | "filterOperator"> {
107
+ if (status === "pending") return { filterField: "signupApproved", filterValue: false, filterOperator: "eq" };
108
+ if (status === "banned") return { filterField: "banned", filterValue: true, filterOperator: "eq" };
109
+ if (role) return { filterField: "role", filterValue: role, filterOperator: "eq" };
110
+ return {};
111
+ }
112
+
113
+ /** @deprecated Use `adminUsersQuery`; `listUsers` accepts only one filter. */
114
+ export function listUsersQuery(state: UserListState): ListUsersQuery {
115
+ const sort = state.sort && SORTABLE_FIELDS.has(state.sort.key) ? state.sort : DEFAULT_USER_SORT;
116
+ const searchField = state.searchField === "name" ? "name" : "email";
117
+ const search = state.search.trim();
118
+ return {
119
+ limit: state.perPage,
120
+ offset: (Math.max(state.page, 1) - 1) * state.perPage,
121
+ sortBy: sort.key,
122
+ sortDirection: sort.dir,
123
+ ...(search
124
+ ? {
125
+ searchValue: searchField === "email" ? search.toLowerCase() : search,
126
+ searchField,
127
+ searchOperator: "contains" as const,
128
+ }
129
+ : {}),
130
+ ...filterFor(state.role, state.status),
131
+ };
132
+ }
133
+
134
+ /** @deprecated `GET /admin/users` combines the role and status filters. */
135
+ export function keepLatestFilter(
136
+ previous: { role: string; status: string },
137
+ next: { role: string; status: string },
138
+ ): { role: string; status: string } {
139
+ if (!next.role || !next.status) return next;
140
+ return next.status !== previous.status ? { role: "", status: next.status } : { role: next.role, status: "" };
141
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ auditLogUrl,
4
+ cursorForPage,
5
+ emptyTrail,
6
+ fetchAuditLogPage,
7
+ recordPage,
8
+ trailCount,
9
+ } from "./audit-log";
10
+
11
+ const none = { action: "", actor: "", target: "" };
12
+
13
+ describe("auditLogUrl", () => {
14
+ it("sends only the filters that are set", () => {
15
+ expect(auditLogUrl(none, "desc", null, 15)).toBe("/api/audit-logs?limit=15");
16
+ expect(auditLogUrl({ action: "user.ban", actor: " a@example.com ", target: "" }, "asc", "abc", 15))
17
+ .toBe("/api/audit-logs?limit=15&order=asc&cursor=abc&action=user.ban&actor=a%40example.com");
18
+ });
19
+ });
20
+
21
+ describe("fetchAuditLogPage", () => {
22
+ it("reads the entries and the next cursor header", async () => {
23
+ const fetcher = async (): Promise<Response> =>
24
+ new Response(JSON.stringify([{ id: "1" }]), { headers: { "x-next-cursor": "next" } });
25
+ await expect(fetchAuditLogPage(fetcher, none, "desc", null, 15)).resolves.toEqual({
26
+ entries: [{ id: "1" }],
27
+ nextCursor: "next",
28
+ });
29
+ });
30
+
31
+ it("fails on an error status", async () => {
32
+ const fetcher = async (): Promise<Response> => new Response("{}", { status: 403 });
33
+ await expect(fetchAuditLogPage(fetcher, none, "desc", null, 15)).rejects.toThrow("403");
34
+ });
35
+ });
36
+
37
+ describe("cursor trail", () => {
38
+ it("reaches one page past the last one loaded until the end appears", () => {
39
+ let trail = emptyTrail();
40
+ expect(cursorForPage(trail, 1)).toBeNull();
41
+ expect(cursorForPage(trail, 2)).toBeUndefined();
42
+
43
+ trail = recordPage(trail, 1, 15, "c2");
44
+ expect(cursorForPage(trail, 2)).toBe("c2");
45
+ expect(trailCount(trail, 15)).toBe(30);
46
+
47
+ trail = recordPage(trail, 2, 15, "c3");
48
+ trail = recordPage(trail, 3, 4, null);
49
+ expect(trail.cursors).toEqual([null, "c2", "c3"]);
50
+ expect(trailCount(trail, 15)).toBe(34);
51
+ });
52
+
53
+ it("keeps later pages when an earlier page still leads to them", () => {
54
+ let trail = recordPage(recordPage(emptyTrail(), 1, 15, "c2"), 2, 15, "c3");
55
+ trail = recordPage(trail, 1, 15, "c2");
56
+ expect(cursorForPage(trail, 3)).toBe("c3");
57
+ });
58
+
59
+ it("forgets later pages when new entries shift an earlier page", () => {
60
+ let trail = recordPage(recordPage(emptyTrail(), 1, 15, "c2"), 2, 3, null);
61
+ trail = recordPage(trail, 1, 15, "c2-shifted");
62
+ expect(trail).toEqual({ cursors: [null, "c2-shifted"], end: null });
63
+ });
64
+
65
+ it("counts an empty first page as no rows", () => {
66
+ expect(trailCount(recordPage(emptyTrail(), 1, 0, null), 15)).toBe(0);
67
+ });
68
+ });
@@ -0,0 +1,108 @@
1
+ // Client for the audit-log module's cursor-paged list (GET /api/audit-logs).
2
+ // The body is an array; the cursor for the next page arrives in a header.
3
+
4
+ export type AuditLogEntry = {
5
+ id: string;
6
+ action: string;
7
+ actorId: string | null;
8
+ actorName: string | null;
9
+ actorEmail: string | null;
10
+ targetLabel: string | null;
11
+ ip: string | null;
12
+ createdAt: string;
13
+ };
14
+
15
+ export type AuditLogOrder = "asc" | "desc";
16
+
17
+ export type AuditLogFilters = {
18
+ action: string;
19
+ /** Exact actor email. */
20
+ actor: string;
21
+ /** Exact target label. */
22
+ target: string;
23
+ };
24
+
25
+ export type AuditLogPage = {
26
+ entries: AuditLogEntry[];
27
+ nextCursor: string | null;
28
+ };
29
+
30
+ export const AUDIT_NEXT_CURSOR_HEADER = "x-next-cursor";
31
+
32
+ export type Fetcher = (url: string) => Promise<Response>;
33
+
34
+ export function auditLogUrl(
35
+ filters: AuditLogFilters,
36
+ order: AuditLogOrder,
37
+ cursor: string | null,
38
+ limit: number,
39
+ ): string {
40
+ const params = new URLSearchParams({ limit: String(limit) });
41
+ if (order === "asc") params.set("order", "asc");
42
+ if (cursor) params.set("cursor", cursor);
43
+ for (const key of ["action", "actor", "target"] as const) {
44
+ const value = filters[key].trim();
45
+ if (value) params.set(key, value);
46
+ }
47
+ return `/api/audit-logs?${params.toString()}`;
48
+ }
49
+
50
+ export async function fetchAuditLogPage(
51
+ fetcher: Fetcher,
52
+ filters: AuditLogFilters,
53
+ order: AuditLogOrder,
54
+ cursor: string | null,
55
+ limit: number,
56
+ ): Promise<AuditLogPage> {
57
+ const response = await fetcher(auditLogUrl(filters, order, cursor, limit));
58
+ if (!response.ok) throw new Error(`Audit log request failed with status ${response.status}`);
59
+ return {
60
+ entries: (await response.json()) as AuditLogEntry[],
61
+ nextCursor: response.headers.get(AUDIT_NEXT_CURSOR_HEADER),
62
+ };
63
+ }
64
+
65
+ export async function fetchAuditActions(fetcher: Fetcher): Promise<string[]> {
66
+ const response = await fetcher("/api/audit-logs/actions");
67
+ if (!response.ok) throw new Error(`Audit action request failed with status ${response.status}`);
68
+ return (await response.json()) as string[];
69
+ }
70
+
71
+ // A cursor-paged list has no total, so the pager learns its pages as they load:
72
+ // `cursors[n]` opens page n + 1, and `end` is set once a page comes back
73
+ // without a next cursor.
74
+ export type CursorTrail = {
75
+ cursors: Array<string | null>;
76
+ end: { page: number; size: number } | null;
77
+ };
78
+
79
+ export function emptyTrail(): CursorTrail {
80
+ return { cursors: [null], end: null };
81
+ }
82
+
83
+ /** The cursor that opens `page`, or undefined when that page is not reachable yet. */
84
+ export function cursorForPage(trail: CursorTrail, page: number): string | null | undefined {
85
+ return page >= 1 ? trail.cursors[page - 1] : undefined;
86
+ }
87
+
88
+ /** Records what loading `page` revealed about the pages after it. */
89
+ export function recordPage(
90
+ trail: CursorTrail,
91
+ page: number,
92
+ size: number,
93
+ nextCursor: string | null,
94
+ ): CursorTrail {
95
+ // An unchanged next cursor means the later pages learned earlier still line up.
96
+ if (nextCursor !== null && trail.cursors[page] === nextCursor) return trail;
97
+ return {
98
+ cursors: [...trail.cursors.slice(0, page), ...(nextCursor === null ? [] : [nextCursor])],
99
+ end: nextCursor === null ? { page, size } : null,
100
+ };
101
+ }
102
+
103
+ /** Row count for the pager: exact once the end is known, else every reachable page as full. */
104
+ export function trailCount(trail: CursorTrail, perPage: number): number {
105
+ return trail.end
106
+ ? (trail.end.page - 1) * perPage + trail.end.size
107
+ : trail.cursors.length * perPage;
108
+ }