@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
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { goto, invalidateAll } from "$app/navigation";
3
- import QRCode from "qrcode";
4
3
  import { Button } from "#lib/components/ui/button/index.js";
5
4
  import { Input } from "#lib/components/ui/input/index.js";
6
5
  import { Label } from "#lib/components/ui/label/index.js";
@@ -252,13 +251,20 @@
252
251
  let qrDataUrl = $state("");
253
252
  $effect(() => {
254
253
  const uri = setup?.totpURI;
255
- if (!uri) {
256
- qrDataUrl = "";
257
- return;
258
- }
259
- QRCode.toDataURL(uri, { margin: 1, width: 176 })
260
- .then((url) => (qrDataUrl = url))
261
- .catch(() => (qrDataUrl = ""));
254
+ qrDataUrl = "";
255
+ if (!uri) return;
256
+ let active = true;
257
+ import("qrcode")
258
+ .then(({ toDataURL }) => toDataURL(uri, { margin: 1, width: 176 }))
259
+ .then((url) => {
260
+ if (active && setup?.totpURI === uri) qrDataUrl = url;
261
+ })
262
+ .catch(() => {
263
+ if (active) qrDataUrl = "";
264
+ });
265
+ return () => {
266
+ active = false;
267
+ };
262
268
  });
263
269
  async function enable2fa(): Promise<void> {
264
270
  twoFaBusy = true;
@@ -1,21 +1,27 @@
1
1
  <script lang="ts">
2
2
  import { goto } from "$app/navigation";
3
- import { onMount } from "svelte";
4
3
  import { api } from "#lib/api.js";
5
4
 
6
5
  let { timeoutMinutes }: { timeoutMinutes: number | null } = $props();
7
6
 
8
7
  const ACTIVITY_KEY = "podokit:session-last-activity";
9
8
  const SERVER_REFRESH_INTERVAL_MS = 60_000;
9
+ // Every localStorage write fires a `storage` event in each other open tab.
10
+ const ACTIVITY_WRITE_INTERVAL_MS = 5_000;
10
11
  const activityEvents = ["pointerdown", "keydown", "touchstart", "scroll"] as const;
11
12
 
12
- let mounted = $state(false);
13
- let lastActivityAt = $state(Date.now());
14
- let lastServerRefreshAt = $state(Date.now());
15
13
  let signingOut = false;
16
- let previousTimeoutMs: number | null = null;
17
14
  const timeoutMs = $derived(timeoutMinutes === null ? null : timeoutMinutes * 60_000);
18
15
 
16
+ function readStoredActivity(): number {
17
+ try {
18
+ const value = Number(localStorage.getItem(ACTIVITY_KEY));
19
+ return Number.isFinite(value) ? value : 0;
20
+ } catch {
21
+ return 0;
22
+ }
23
+ }
24
+
19
25
  function storeActivity(value: number): void {
20
26
  try {
21
27
  localStorage.setItem(ACTIVITY_KEY, String(value));
@@ -44,66 +50,85 @@
44
50
  if (error || !data) await signOutForIdleTimeout();
45
51
  }
46
52
 
47
- function recordActivity(): void {
48
- if (timeoutMs === null || signingOut) return;
49
- const now = Date.now();
50
- if (now - lastActivityAt >= timeoutMs) {
53
+ function trackActivity(limit: number): () => void {
54
+ // The policy only exists for a signed-in user, so a new tracker means a new session.
55
+ signingOut = false;
56
+ let lastActivityAt = Date.now();
57
+ let storedActivityAt = lastActivityAt;
58
+ let lastWriteAt = lastActivityAt;
59
+ let lastServerRefreshAt = lastActivityAt;
60
+ let deadlineTimer: number | undefined;
61
+ let flushTimer: number | undefined;
62
+ storeActivity(lastActivityAt);
63
+
64
+ function flush(): void {
65
+ window.clearTimeout(flushTimer);
66
+ flushTimer = undefined;
67
+ if (signingOut || lastActivityAt <= storedActivityAt) return;
68
+ storedActivityAt = lastActivityAt;
69
+ lastWriteAt = Date.now();
70
+ storeActivity(storedActivityAt);
71
+ }
72
+
73
+ function expire(): void {
74
+ window.clearTimeout(deadlineTimer);
75
+ window.clearTimeout(flushTimer);
51
76
  void signOutForIdleTimeout();
52
- return;
53
77
  }
54
- lastActivityAt = now;
55
- storeActivity(now);
56
- if (now - lastServerRefreshAt >= SERVER_REFRESH_INTERVAL_MS) {
57
- lastServerRefreshAt = now;
58
- void refreshServerSession();
78
+
79
+ // The deadline is re-read when the timer fires rather than re-armed on every
80
+ // event; other tabs' activity arrives through storage.
81
+ function check(): void {
82
+ if (signingOut) return;
83
+ const latest = Math.max(lastActivityAt, readStoredActivity());
84
+ const remaining = limit - (Date.now() - latest);
85
+ if (remaining <= 0) return expire();
86
+ window.clearTimeout(deadlineTimer);
87
+ deadlineTimer = window.setTimeout(check, remaining);
59
88
  }
60
- }
61
89
 
62
- function receiveActivity(event: StorageEvent): void {
63
- if (event.key !== ACTIVITY_KEY || event.newValue === null) return;
64
- const value = Number(event.newValue);
65
- if (Number.isFinite(value) && value > lastActivityAt) lastActivityAt = value;
66
- }
90
+ function recordActivity(): void {
91
+ if (signingOut) return;
92
+ const now = Date.now();
93
+ // Timers can be suspended (sleep, background throttling), so a late event may arrive past the deadline.
94
+ if (now - lastActivityAt >= limit && now - readStoredActivity() >= limit) return expire();
95
+ lastActivityAt = now;
96
+ const sinceWrite = now - lastWriteAt;
97
+ if (sinceWrite >= ACTIVITY_WRITE_INTERVAL_MS) flush();
98
+ else flushTimer ??= window.setTimeout(flush, ACTIVITY_WRITE_INTERVAL_MS - sinceWrite);
99
+ if (now - lastServerRefreshAt >= SERVER_REFRESH_INTERVAL_MS) {
100
+ lastServerRefreshAt = now;
101
+ void refreshServerSession();
102
+ }
103
+ }
104
+
105
+ function onVisibilityChange(): void {
106
+ if (document.visibilityState === "hidden") flush();
107
+ else check();
108
+ }
67
109
 
68
- onMount(() => {
69
- mounted = true;
70
- const now = Date.now();
71
- lastActivityAt = now;
72
- lastServerRefreshAt = now;
73
- storeActivity(now);
74
110
  for (const event of activityEvents) {
75
111
  document.addEventListener(event, recordActivity, { passive: true, capture: true });
76
112
  }
77
113
  window.addEventListener("focus", recordActivity);
78
- window.addEventListener("storage", receiveActivity);
114
+ document.addEventListener("visibilitychange", onVisibilityChange);
115
+ window.addEventListener("pagehide", flush);
116
+ deadlineTimer = window.setTimeout(check, limit);
117
+
79
118
  return () => {
80
119
  for (const event of activityEvents) {
81
120
  document.removeEventListener(event, recordActivity, { capture: true });
82
121
  }
83
122
  window.removeEventListener("focus", recordActivity);
84
- window.removeEventListener("storage", receiveActivity);
123
+ document.removeEventListener("visibilitychange", onVisibilityChange);
124
+ window.removeEventListener("pagehide", flush);
125
+ window.clearTimeout(deadlineTimer);
126
+ window.clearTimeout(flushTimer);
85
127
  };
86
- });
128
+ }
87
129
 
88
130
  $effect(() => {
89
- if (!mounted) return;
90
- const currentTimeoutMs = timeoutMs;
91
- if (currentTimeoutMs !== previousTimeoutMs) {
92
- previousTimeoutMs = currentTimeoutMs;
93
- if (currentTimeoutMs !== null) {
94
- const now = Date.now();
95
- lastActivityAt = now;
96
- lastServerRefreshAt = now;
97
- storeActivity(now);
98
- }
99
- }
100
- if (currentTimeoutMs === null) return;
101
- const remaining = currentTimeoutMs - (Date.now() - lastActivityAt);
102
- if (remaining <= 0) {
103
- void signOutForIdleTimeout();
104
- return;
105
- }
106
- const timer = window.setTimeout(() => void signOutForIdleTimeout(), remaining);
107
- return () => window.clearTimeout(timer);
131
+ if (timeoutMs === null) return;
132
+ return trackActivity(timeoutMs);
108
133
  });
109
134
  </script>
@@ -393,7 +393,10 @@
393
393
  "empty": "No audit entries.",
394
394
  "loadFailed": "Failed to load audit log",
395
395
  "total": "{count} entries",
396
- "searchField": "Actor or target"
396
+ "range": "Entries {from}–{to}",
397
+ "searchField": "Actor or target",
398
+ "actorHint": "Full email address",
399
+ "targetHint": "Full target as shown"
397
400
  },
398
401
  "users": {
399
402
  "title": "Users",
@@ -503,6 +506,8 @@
503
506
  "revoked": "Session revoked",
504
507
  "allRevoked": "All sessions revoked for user",
505
508
  "loadFailed": "Failed to load sessions",
509
+ "revokeFailed": "Failed to revoke the session",
510
+ "searchPlaceholder": "Email or name",
506
511
  "total": "{count} sessions"
507
512
  },
508
513
  "account": {
@@ -393,7 +393,10 @@
393
393
  "empty": "감사 기록이 없습니다.",
394
394
  "loadFailed": "감사 로그를 불러오지 못했습니다",
395
395
  "total": "항목 {count}개",
396
- "searchField": "수행자·대상"
396
+ "range": "{from}–{to}번째 항목",
397
+ "searchField": "수행자·대상",
398
+ "actorHint": "전체 이메일 주소",
399
+ "targetHint": "표시된 대상 전체"
397
400
  },
398
401
  "users": {
399
402
  "title": "회원",
@@ -503,6 +506,8 @@
503
506
  "revoked": "세션을 종료했습니다",
504
507
  "allRevoked": "사용자의 세션을 모두 종료했습니다",
505
508
  "loadFailed": "세션을 불러오지 못했습니다",
509
+ "revokeFailed": "세션을 종료하지 못했습니다",
510
+ "searchPlaceholder": "이메일 또는 이름",
506
511
  "total": "세션 {count}개"
507
512
  },
508
513
  "account": {
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { loadMessages } from "./messages";
3
+
4
+ describe("loadMessages", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs();
7
+ });
8
+
9
+ it("reuses one merged catalog per locale pair in a build", async () => {
10
+ vi.stubEnv("DEV", false);
11
+
12
+ const korean = await loadMessages("ko", "en");
13
+
14
+ expect(korean.nav.users).toBe("회원");
15
+ expect(await loadMessages("ko", "en")).toBe(korean);
16
+ expect(await loadMessages("KO", null)).toBe(korean);
17
+ expect(await loadMessages("en", "en")).not.toBe(korean);
18
+ });
19
+
20
+ it("merges again in development so edited catalogs appear", async () => {
21
+ vi.stubEnv("DEV", true);
22
+
23
+ const first = await loadMessages("en");
24
+ const second = await loadMessages("en");
25
+
26
+ expect(second).not.toBe(first);
27
+ expect(second).toEqual(first);
28
+ });
29
+ });
@@ -83,12 +83,7 @@ async function loadCatalog(code: string): Promise<JsonObject> {
83
83
  return catalog;
84
84
  }
85
85
 
86
- export async function loadMessages(
87
- locale: string,
88
- configuredFallback?: string | null,
89
- ): Promise<Messages> {
90
- const selected = resolveLocale(locale);
91
- const fallback = resolveLocale(configuredFallback);
86
+ async function mergeMessages(selected: Locale, fallback: Locale): Promise<Messages> {
92
87
  const candidates = [defaultLocale, fallback, selected];
93
88
  // A later catalog has higher priority. Keep the last occurrence so selecting
94
89
  // the built-in English fallback still overrides a non-English site default.
@@ -97,3 +92,25 @@ export async function loadMessages(
97
92
  for (const code of chain) catalog = mergeCatalog(catalog, await loadCatalog(code));
98
93
  return catalog as Messages;
99
94
  }
95
+
96
+ // Catalogs are static in a build, so each locale pair is merged once and every
97
+ // caller shares the same object. Treat it as read-only.
98
+ const merged = new Map<string, Promise<Messages>>();
99
+
100
+ export function loadMessages(
101
+ locale: string,
102
+ configuredFallback?: string | null,
103
+ ): Promise<Messages> {
104
+ const selected = resolveLocale(locale);
105
+ const fallback = resolveLocale(configuredFallback);
106
+ // Development re-merges so an edited catalog shows up on the next load.
107
+ if (import.meta.env.DEV) return mergeMessages(selected, fallback);
108
+ const key = `${selected}\u0000${fallback}`;
109
+ let messages = merged.get(key);
110
+ if (!messages) {
111
+ messages = mergeMessages(selected, fallback);
112
+ messages.catch(() => merged.delete(key));
113
+ merged.set(key, messages);
114
+ }
115
+ return messages;
116
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { signedOAuthQuery } from "./oauth-query";
3
+
4
+ describe("signed OAuth query", () => {
5
+ it("keeps only the signed parameters", () => {
6
+ const search = "?client_id=c1&scope=openid&exp=9&ba_param=client_id&ba_param=exp&ba_param=scope&sig=abc&extra=1";
7
+ const query = new URLSearchParams(signedOAuthQuery(search));
8
+ expect(query.get("client_id")).toBe("c1");
9
+ expect(query.get("sig")).toBe("abc");
10
+ expect(query.getAll("ba_param")).toEqual(["client_id", "exp", "scope"]);
11
+ expect(query.has("extra")).toBe(false);
12
+ });
13
+
14
+ it("forwards the whole query when the server lists no signed names", () => {
15
+ expect(signedOAuthQuery("?client_id=c1&sig=abc")).toBe("client_id=c1&sig=abc");
16
+ });
17
+
18
+ it("returns nothing without a signature", () => {
19
+ expect(signedOAuthQuery("?client_id=c1")).toBeUndefined();
20
+ });
21
+ });
@@ -0,0 +1,15 @@
1
+ // The OAuth provider's consent endpoint verifies a signed copy of the authorization
2
+ // request sent in the body. Like Better Auth's own client plugin, forward only the
3
+ // parameters the server signed, so extra page parameters cannot break the signature.
4
+ export function signedOAuthQuery(search: string): string | undefined {
5
+ const params = new URLSearchParams(search);
6
+ if (!params.has("sig")) return undefined;
7
+ const signedNames = params.getAll("ba_param");
8
+ if (signedNames.length === 0) return params.toString();
9
+ const allowed = new Set(signedNames);
10
+ const signed = new URLSearchParams();
11
+ for (const [key, value] of params.entries()) {
12
+ if (key === "sig" || key === "ba_param" || allowed.has(key)) signed.append(key, value);
13
+ }
14
+ return signed.toString();
15
+ }
@@ -0,0 +1,98 @@
1
+ import type { Capabilities } from "@podosoft/podokit-api-client";
2
+ import { describe, expect, it, vi, type Mock } from "vitest";
3
+ import { DEFAULT_CAPABILITIES, getCapabilities } from "./capabilities";
4
+ import { loadProtectedLayout } from "./protected-layout";
5
+
6
+ type TestUser = NonNullable<App.Locals["user"]>;
7
+ type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>;
8
+
9
+ function fetchMock(respond: () => Promise<Response>): FetchMock {
10
+ return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(respond);
11
+ }
12
+
13
+ function locals(user: TestUser | null = null): App.Locals {
14
+ return {
15
+ user,
16
+ session: user ? { id: "session-1", impersonatedBy: null } : null,
17
+ authUnavailable: false,
18
+ locale: "en",
19
+ site: null,
20
+ siteUnavailable: false,
21
+ };
22
+ }
23
+
24
+ function capabilitiesFetch(capabilities: Partial<Capabilities>): FetchMock {
25
+ return fetchMock(async () => Response.json({ ...DEFAULT_CAPABILITIES, twoFactor: true, ...capabilities }));
26
+ }
27
+
28
+ const member: TestUser = { id: "user-1", email: "user@example.com", name: "User", twoFactorEnabled: false };
29
+
30
+ describe("getCapabilities", () => {
31
+ it("fetches once for every load in the same request", async () => {
32
+ const fetch = capabilitiesFetch({ auditLog: true });
33
+ const request = locals(member);
34
+
35
+ const [first, second] = await Promise.all([
36
+ getCapabilities({ locals: request, fetch }),
37
+ getCapabilities({ locals: request, fetch }),
38
+ ]);
39
+ const third = await getCapabilities({ locals: request, fetch });
40
+
41
+ expect(fetch).toHaveBeenCalledTimes(1);
42
+ expect(fetch).toHaveBeenCalledWith("/api/account/capabilities");
43
+ expect(first.auditLog).toBe(true);
44
+ expect(second).toBe(first);
45
+ expect(third).toBe(first);
46
+ });
47
+
48
+ it("fetches again for a new request", async () => {
49
+ const fetch = capabilitiesFetch({});
50
+
51
+ await getCapabilities({ locals: locals(member), fetch });
52
+ await getCapabilities({ locals: locals(member), fetch });
53
+
54
+ expect(fetch).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it("resolves to safe defaults when the endpoint fails", async () => {
58
+ const rejected = fetchMock(async () => {
59
+ throw new TypeError("fetch failed");
60
+ });
61
+ const unavailable = fetchMock(async () => new Response("", { status: 503 }));
62
+
63
+ await expect(getCapabilities({ locals: locals(), fetch: rejected })).resolves.toEqual(DEFAULT_CAPABILITIES);
64
+ await expect(getCapabilities({ locals: locals(), fetch: unavailable })).resolves.toEqual(DEFAULT_CAPABILITIES);
65
+ });
66
+ });
67
+
68
+ describe("loadProtectedLayout", () => {
69
+ it("sends an un-enrolled user to setup when the policy requires two-factor", async () => {
70
+ const fetch = capabilitiesFetch({ require2fa: true });
71
+
72
+ await expect(loadProtectedLayout({ locals: locals(member), fetch })).rejects.toMatchObject({
73
+ status: 302,
74
+ location: "/setup-2fa",
75
+ });
76
+ expect(fetch).toHaveBeenCalledTimes(1);
77
+ expect(fetch).not.toHaveBeenCalledWith("/api/account/require-2fa");
78
+ });
79
+
80
+ it("lets enrolled users through and shares the capabilities response", async () => {
81
+ const fetch = capabilitiesFetch({ require2fa: true });
82
+ const request = locals({ ...member, twoFactorEnabled: true });
83
+
84
+ const data = await loadProtectedLayout({ locals: request, fetch });
85
+
86
+ expect(data.capabilities.require2fa).toBe(true);
87
+ expect(data.capabilities).toBe(await getCapabilities({ locals: request, fetch }));
88
+ expect(fetch).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("fails closed before fetching when the backend could not be checked", async () => {
92
+ const fetch = capabilitiesFetch({});
93
+ const request = { ...locals(member), authUnavailable: true };
94
+
95
+ await expect(loadProtectedLayout({ locals: request, fetch })).rejects.toMatchObject({ status: 503 });
96
+ expect(fetch).not.toHaveBeenCalled();
97
+ });
98
+ });
@@ -0,0 +1,54 @@
1
+ import type { Capabilities } from "@podosoft/podokit-api-client";
2
+
3
+ type ServerFetch = (
4
+ input: RequestInfo | URL,
5
+ init?: RequestInit,
6
+ ) => Promise<Response>;
7
+
8
+ export interface CapabilitiesEvent {
9
+ locals: App.Locals;
10
+ fetch: ServerFetch;
11
+ }
12
+
13
+ /** Safe values used when the capability endpoint cannot be reached. */
14
+ export const DEFAULT_CAPABILITIES: Capabilities = {
15
+ twoFactor: false,
16
+ providers: [],
17
+ deleteAccount: false,
18
+ auditLog: false,
19
+ emailVerification: false,
20
+ signupApprovalRequired: false,
21
+ passwordBreachCheck: false,
22
+ magicLink: false,
23
+ emailOtp: false,
24
+ username: false,
25
+ multiSession: false,
26
+ phoneNumber: false,
27
+ apiKey: false,
28
+ passkey: false,
29
+ organization: false,
30
+ oidcProvider: false,
31
+ require2fa: false,
32
+ roles: ["admin", "user"],
33
+ };
34
+
35
+ async function fetchCapabilities(fetch: ServerFetch): Promise<Capabilities> {
36
+ try {
37
+ const response = await fetch("/api/account/capabilities");
38
+ if (response.ok) return (await response.json()) as Capabilities;
39
+ } catch {
40
+ /* Keep safe defaults if the capability endpoint is unavailable. */
41
+ }
42
+ return DEFAULT_CAPABILITIES;
43
+ }
44
+
45
+ /**
46
+ * Load `/account/capabilities` at most once per request. Every server load of
47
+ * one page render or `__data.json` request shares `locals`, so layouts and
48
+ * pages can call this freely. The promise never rejects; failures resolve to
49
+ * {@link DEFAULT_CAPABILITIES}.
50
+ */
51
+ export function getCapabilities({ locals, fetch }: CapabilitiesEvent): Promise<Capabilities> {
52
+ locals.capabilities ??= fetchCapabilities(fetch);
53
+ return locals.capabilities;
54
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Stands in for the application's own list in the app-owned public-paths.ts.
4
+ vi.mock("./public-paths", () => ({ APP_PUBLIC_PATHS: ["/pricing", "/robots.txt"] }));
5
+
6
+ const { isPublicPath } = await import("./guards");
7
+
8
+ describe("isPublicPath", () => {
9
+ it("keeps the sign-in flow and the landing page public", () => {
10
+ expect(isPublicPath("/")).toBe(true);
11
+ expect(isPublicPath("/login")).toBe(true);
12
+ expect(isPublicPath("/reset-password/token")).toBe(true);
13
+ expect(isPublicPath("/admin")).toBe(false);
14
+ expect(isPublicPath("/account")).toBe(false);
15
+ });
16
+
17
+ it("adds the application's public prefixes and their subpaths", () => {
18
+ expect(isPublicPath("/pricing")).toBe(true);
19
+ expect(isPublicPath("/pricing/teams")).toBe(true);
20
+ expect(isPublicPath("/robots.txt")).toBe(true);
21
+ expect(isPublicPath("/pricingx")).toBe(false);
22
+ });
23
+ });
@@ -1,4 +1,5 @@
1
1
  import { error } from "@sveltejs/kit";
2
+ import { APP_PUBLIC_PATHS } from "./public-paths";
2
3
 
3
4
  /** Roles that may reach the admin console. Kept as a set so custom roles can be
4
5
  * added here (and, later, replaced by a permission check) without touching every
@@ -13,6 +14,7 @@ const PUBLIC_PATHS = [
13
14
  "/verify-email",
14
15
  "/pending-approval",
15
16
  "/maintenance",
17
+ ...APP_PUBLIC_PATHS,
16
18
  // Modules add their public (no-session) page prefixes here.
17
19
  // podokit:begin:public-paths
18
20
  // podokit:end:public-paths
@@ -1,16 +1,9 @@
1
1
  import { redirect } from "@sveltejs/kit";
2
2
  import type { Capabilities } from "@podosoft/podokit-api-client";
3
+ import { getCapabilities, type CapabilitiesEvent } from "./capabilities";
3
4
  import { requireBackendAvailable } from "./guards";
4
5
 
5
- type ServerFetch = (
6
- input: RequestInfo | URL,
7
- init?: RequestInit,
8
- ) => Promise<Response>;
9
-
10
- export interface ProtectedLayoutInput {
11
- locals: App.Locals;
12
- fetch: ServerFetch;
13
- }
6
+ export type ProtectedLayoutInput = CapabilitiesEvent;
14
7
 
15
8
  export interface ProtectedLayoutData {
16
9
  user: App.Locals["user"];
@@ -22,53 +15,17 @@ export interface ProtectedLayoutData {
22
15
  * Load the shared signed-in route context without imposing an application shell.
23
16
  * The `(app)` group stays available for product UI, while `(admin)` adds admin chrome.
24
17
  */
25
- export async function loadProtectedLayout({
26
- locals,
27
- fetch,
28
- }: ProtectedLayoutInput): Promise<ProtectedLayoutData> {
18
+ export async function loadProtectedLayout(event: ProtectedLayoutInput): Promise<ProtectedLayoutData> {
19
+ const { locals } = event;
29
20
  requireBackendAvailable(locals);
30
21
 
31
- let capabilities: Capabilities = {
32
- twoFactor: false,
33
- providers: [],
34
- deleteAccount: false,
35
- auditLog: false,
36
- emailVerification: false,
37
- signupApprovalRequired: false,
38
- passwordBreachCheck: false,
39
- magicLink: false,
40
- emailOtp: false,
41
- username: false,
42
- multiSession: false,
43
- phoneNumber: false,
44
- apiKey: false,
45
- passkey: false,
46
- organization: false,
47
- oidcProvider: false,
48
- roles: ["admin", "user"],
49
- };
50
- try {
51
- const response = await fetch("/api/account/capabilities");
52
- if (response.ok) capabilities = (await response.json()) as Capabilities;
53
- } catch {
54
- /* keep defaults */
55
- }
22
+ const capabilities = await getCapabilities(event);
56
23
 
57
24
  // Require-two-factor policy: a signed-in user who has not enrolled is sent to
58
25
  // setup. The API guard remains authoritative; this redirect provides the UX.
59
- const user = locals.user as (App.Locals["user"] & { twoFactorEnabled?: boolean }) | null;
60
- let mustEnrol = false;
61
- if (user && !user.twoFactorEnabled) {
62
- try {
63
- const response = await fetch("/api/account/require-2fa");
64
- mustEnrol =
65
- response.ok &&
66
- ((await response.json()) as { require2fa?: boolean }).require2fa === true;
67
- } catch {
68
- /* do not block the app if the policy check fails */
69
- }
26
+ if (locals.user && !locals.user.twoFactorEnabled && capabilities.require2fa) {
27
+ redirect(302, "/setup-2fa");
70
28
  }
71
- if (mustEnrol) redirect(302, "/setup-2fa");
72
29
 
73
30
  return {
74
31
  user: locals.user,
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Application-owned public page prefixes. A listed path also covers its
3
+ * subpaths ("/pricing" covers "/pricing/teams"). These pages render for
4
+ * anonymous visitors and keep rendering when the API or site settings are
5
+ * unavailable; every other page requires a session and fails closed with 503
6
+ * during an outage. Modules add their own prefixes in guards.ts, so do not
7
+ * repeat them here. PodoKit creates this file once and never overwrites it.
8
+ */
9
+ export const APP_PUBLIC_PATHS: readonly string[] = [];