@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
@@ -18,29 +18,35 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
18
18
  { flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
19
19
  ];
20
20
 
21
- const CACHE_TTL_MS = 3_000;
21
+ export type FeatureFlags = Record<FeatureFlag, boolean>;
22
22
 
23
- export type SignupOpenCheck = () => Promise<boolean>;
24
- export type AppSettingsReader = () => Promise<Array<{ key: string; value: string }>>;
23
+ /** Live settings the gate reads per request, normally the shared settings snapshot. */
24
+ export interface FeatureGateSettings {
25
+ flags: () => Promise<FeatureFlags>;
26
+ signupOpen: () => Promise<boolean>;
27
+ }
28
+
29
+ /** Public registration is the default until an administrator explicitly closes it. */
30
+ export function signupOpenFrom(settings: ReadonlyMap<string, string>): boolean {
31
+ return settings.get("site.allowSignup") !== "false";
32
+ }
25
33
 
26
- /** Public registration is the default until an administrator explicitly closes
27
- * it. The short cache keeps every auth flow on the same live site-setting
28
- * policy without rebuilding the Better Auth instance. */
29
- export function createSignupOpenCheck(readSettings: AppSettingsReader): SignupOpenCheck {
30
- let signupOpen = true;
31
- let fetchedAt = 0;
34
+ /** A settings read failure keeps public sign-up open. */
35
+ export async function readSignupOpen(signupOpen: () => Promise<boolean>): Promise<boolean> {
36
+ try {
37
+ return await signupOpen();
38
+ } catch {
39
+ return true;
40
+ }
41
+ }
32
42
 
33
- return async (): Promise<boolean> => {
34
- if (Date.now() - fetchedAt < CACHE_TTL_MS) return signupOpen;
35
- try {
36
- const rows = await readSettings();
37
- signupOpen = rows.find((row) => row.key === "site.allowSignup")?.value !== "false";
38
- } catch {
39
- signupOpen = true;
40
- }
41
- fetchedAt = Date.now();
42
- return signupOpen;
43
- };
43
+ /** A settings read failure applies the shipped defaults. */
44
+ export async function readFeatureFlags(flags: () => Promise<FeatureFlags>): Promise<FeatureFlags> {
45
+ try {
46
+ return await flags();
47
+ } catch {
48
+ return { ...FLAG_DEFAULTS };
49
+ }
44
50
  }
45
51
 
46
52
  /** Administrator-created users are intentional and remain available when the
@@ -60,45 +66,28 @@ export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: str
60
66
 
61
67
  /** Server-side enforcement for the admin Settings toggles: requests to a disabled
62
68
  * feature's endpoints are rejected with 404, so "off" is a real boundary rather
63
- * than hidden UI. Flags are read from app_setting through a small TTL cache
64
- * (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
65
- * the shipped defaults apply. */
66
- export function createFeatureGate(
67
- readSettings: AppSettingsReader,
68
- isSignupOpen: SignupOpenCheck = createSignupOpenCheck(readSettings),
69
- ) {
70
- let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
71
- let fetchedAt = 0;
72
-
73
- async function flags(): Promise<Record<FeatureFlag, boolean>> {
74
- if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
75
- try {
76
- const rows = await readSettings();
77
- const next = { ...FLAG_DEFAULTS };
78
- for (const row of rows) {
79
- if (row.key in FLAG_DEFAULTS) next[row.key as FeatureFlag] = row.value === "true";
80
- }
81
- cache = next;
82
- } catch {
83
- cache = { ...FLAG_DEFAULTS };
84
- }
85
- fetchedAt = Date.now();
86
- return cache;
69
+ * than hidden UI. Throws the API error for a rejected path. */
70
+ export async function enforceFeatureGate(path: string, settings: FeatureGateSettings): Promise<void> {
71
+ // Reject explicit sign-up endpoints early. OAuth starts remain available so
72
+ // existing social users can sign in; their new-user callback is enforced by
73
+ // the database hook that uses this same policy.
74
+ if (path === "/sign-up/email" || path.startsWith("/sign-up/")) {
75
+ assertUserCreationAllowed(await readSignupOpen(settings.signupOpen), path);
76
+ return;
87
77
  }
78
+ const feature = FEATURE_PATHS.find((f) =>
79
+ f.prefixes.some((p) => path === p || path.startsWith(p)),
80
+ );
81
+ if (!feature) return;
82
+ const enabled = (await readFeatureFlags(settings.flags))[feature.flag];
83
+ if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
84
+ }
88
85
 
86
+ /** Better Auth middleware over {@link enforceFeatureGate}. The settings come from
87
+ * the shared snapshot (a toggle takes effect within its TTL); before the
88
+ * migration has run, the shipped defaults apply. */
89
+ export function createFeatureGate(settings: FeatureGateSettings): ReturnType<typeof createAuthMiddleware> {
89
90
  return createAuthMiddleware(async (ctx) => {
90
- // Reject explicit sign-up endpoints early. OAuth starts remain available so
91
- // existing social users can sign in; their new-user callback is enforced by
92
- // the database hook that uses this same policy.
93
- if (ctx.path === "/sign-up/email" || ctx.path.startsWith("/sign-up/")) {
94
- assertUserCreationAllowed(await isSignupOpen(), ctx.path);
95
- return;
96
- }
97
- const feature = FEATURE_PATHS.find((f) =>
98
- f.prefixes.some((p) => ctx.path === p || ctx.path.startsWith(p)),
99
- );
100
- if (!feature) return;
101
- const enabled = (await flags())[feature.flag];
102
- if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
91
+ await enforceFeatureGate(ctx.path, settings);
103
92
  });
104
93
  }
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { QueryRunner } from "typeorm";
4
+ import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
5
+ import { readAuthConfigRow, readAuthConfigRows, writeAuthConfigRow } from "./auth-config.rows";
6
+
7
+ let open: SQL | undefined;
8
+
9
+ afterEach(async () => {
10
+ await open?.close();
11
+ open = undefined;
12
+ });
13
+
14
+ async function sqliteDatabase(): Promise<SQL> {
15
+ const sql = new SQL(":memory:");
16
+ open = sql;
17
+ const runner = {
18
+ connection: { options: { type: "better-sqlite3" } },
19
+ query: (query: string): Promise<unknown> => sql.unsafe(query),
20
+ } as unknown as QueryRunner;
21
+ await new InitAuthConfig1720500000000().up(runner);
22
+ return sql;
23
+ }
24
+
25
+ describe("auth config rows on SQLite", () => {
26
+ it("reads the integer enabled column back as a boolean", async () => {
27
+ const sql = await sqliteDatabase();
28
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: { host: "mail" }, secret: null });
29
+ await writeAuthConfigRow(sql, "sqlite", { key: "social.github", enabled: false, config: {}, secret: null });
30
+
31
+ expect(await readAuthConfigRow(sql, "smtp")).toEqual({
32
+ key: "smtp",
33
+ enabled: true,
34
+ config: JSON.stringify({ host: "mail" }),
35
+ secret: null,
36
+ });
37
+ const rows = await readAuthConfigRows(sql);
38
+ expect(rows.map((row) => [row.key, row.enabled])).toEqual([
39
+ ["smtp", true],
40
+ ["social.github", false],
41
+ ]);
42
+ expect(await readAuthConfigRow(sql, "server")).toBeNull();
43
+ });
44
+
45
+ it("keeps a boolean through a read and rewrite", async () => {
46
+ const sql = await sqliteDatabase();
47
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: {}, secret: null });
48
+ const current = await readAuthConfigRow(sql, "smtp");
49
+ if (!current) throw new Error("expected the smtp row");
50
+ await writeAuthConfigRow(sql, "sqlite", { ...current, secret: "sealed" });
51
+ expect((await readAuthConfigRow(sql, "smtp"))?.enabled).toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,65 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { authConfigParam } from "./config-param";
4
+
5
+ export interface AuthConfigRow {
6
+ key: string;
7
+ enabled: boolean;
8
+ config: unknown;
9
+ secret: string | null;
10
+ }
11
+
12
+ function configObject(value: unknown): Record<string, unknown> {
13
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
14
+ return value as Record<string, unknown>;
15
+ }
16
+ if (typeof value === "string") {
17
+ try {
18
+ return configObject(JSON.parse(value) as unknown);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ return {};
24
+ }
25
+
26
+ // SQLite stores booleans as 0 or 1.
27
+ interface StoredAuthConfigRow extends Omit<AuthConfigRow, "enabled"> {
28
+ enabled: boolean | number;
29
+ }
30
+
31
+ function authConfigRow(row: StoredAuthConfigRow): AuthConfigRow {
32
+ return { ...row, enabled: row.enabled === true || row.enabled === 1 };
33
+ }
34
+
35
+ export async function readAuthConfigRows(sql: SQL): Promise<AuthConfigRow[]> {
36
+ const rows = await sql<StoredAuthConfigRow[]>`
37
+ SELECT "key", "enabled", "config", "secret" FROM "auth_config"
38
+ `;
39
+ return rows.map(authConfigRow);
40
+ }
41
+
42
+ export async function readAuthConfigRow(sql: SQL, key: string): Promise<AuthConfigRow | null> {
43
+ const rows = await sql<StoredAuthConfigRow[]>`
44
+ SELECT "key", "enabled", "config", "secret"
45
+ FROM "auth_config" WHERE "key" = ${key}
46
+ `;
47
+ return rows[0] ? authConfigRow(rows[0]) : null;
48
+ }
49
+
50
+ export async function writeAuthConfigRow(
51
+ sql: SQL,
52
+ provider: DatabaseProviderName,
53
+ row: AuthConfigRow,
54
+ ): Promise<void> {
55
+ const config = authConfigParam(provider, configObject(row.config));
56
+ await sql`
57
+ INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
58
+ VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
59
+ ON CONFLICT ("key") DO UPDATE SET
60
+ "enabled" = EXCLUDED."enabled",
61
+ "config" = EXCLUDED."config",
62
+ "secret" = EXCLUDED."secret",
63
+ "updatedAt" = CURRENT_TIMESTAMP
64
+ `;
65
+ }
@@ -10,7 +10,15 @@ import {
10
10
  SUPPORTED_PROVIDER_IDS,
11
11
  SUPPORTED_SOCIAL_PROVIDERS,
12
12
  } from "@podosoft/podokit-auth";
13
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
13
14
  import { refreshAuthNow } from "../auth/auth-provider";
15
+ import {
16
+ readAuthConfigRow,
17
+ readAuthConfigRows,
18
+ writeAuthConfigRow,
19
+ type AuthConfigRow,
20
+ } from "./auth-config.rows";
21
+ import { resetSessionExpirations } from "./session-expiry";
14
22
 
15
23
  export type SocialProviderView = {
16
24
  id: string;
@@ -71,13 +79,6 @@ export type AuthConfigUpdate = {
71
79
  };
72
80
  };
73
81
 
74
- interface AuthConfigRow {
75
- key: string;
76
- enabled: boolean;
77
- config: unknown;
78
- secret: string | null;
79
- }
80
-
81
82
  function object(value: unknown): Record<string, unknown> {
82
83
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
83
84
  return value as Record<string, unknown>;
@@ -102,33 +103,21 @@ function booleanField(source: Record<string, unknown>, key: string, fallback: bo
102
103
  }
103
104
 
104
105
  export class AuthConfigService {
105
- constructor(private readonly sql: SQL) {}
106
+ constructor(
107
+ private readonly sql: SQL,
108
+ private readonly provider: DatabaseProviderName = "postgres",
109
+ ) {}
106
110
 
107
111
  private async rows(): Promise<AuthConfigRow[]> {
108
- return this.sql<AuthConfigRow[]>`
109
- SELECT "key", "enabled", "config", "secret" FROM "auth_config"
110
- `;
112
+ return readAuthConfigRows(this.sql);
111
113
  }
112
114
 
113
115
  private async row(key: string): Promise<AuthConfigRow | null> {
114
- const rows = await this.sql<AuthConfigRow[]>`
115
- SELECT "key", "enabled", "config", "secret"
116
- FROM "auth_config" WHERE "key" = ${key}
117
- `;
118
- return rows[0] ?? null;
116
+ return readAuthConfigRow(this.sql, key);
119
117
  }
120
118
 
121
119
  private async upsert(row: AuthConfigRow): Promise<void> {
122
- const config = object(row.config);
123
- await this.sql`
124
- INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
125
- VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
126
- ON CONFLICT ("key") DO UPDATE SET
127
- "enabled" = EXCLUDED."enabled",
128
- "config" = EXCLUDED."config",
129
- "secret" = EXCLUDED."secret",
130
- "updatedAt" = CURRENT_TIMESTAMP
131
- `;
120
+ await writeAuthConfigRow(this.sql, this.provider, row);
132
121
  }
133
122
 
134
123
  async describe(): Promise<AuthConfigView> {
@@ -211,7 +200,12 @@ export class AuthConfigService {
211
200
  if (update.server) await this.upsertServer(update.server);
212
201
  await refreshAuthNow();
213
202
  if (update.server?.sessionIdleTimeoutMinutes !== undefined) {
214
- await this.resetSessionExpirations(update.server.sessionIdleTimeoutMinutes);
203
+ const minutes = update.server.sessionIdleTimeoutMinutes;
204
+ await resetSessionExpirations(
205
+ this.sql,
206
+ this.provider,
207
+ minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60,
208
+ );
215
209
  }
216
210
  return this.describe();
217
211
  }
@@ -280,14 +274,4 @@ export class AuthConfigService {
280
274
  }
281
275
  await this.upsert({ ...current, config });
282
276
  }
283
-
284
- private async resetSessionExpirations(minutes: number | null): Promise<void> {
285
- const lifetimeSeconds = minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60;
286
- await this.sql`
287
- UPDATE "session"
288
- SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
289
- "updatedAt" = CURRENT_TIMESTAMP
290
- WHERE "expiresAt" > CURRENT_TIMESTAMP
291
- `;
292
- }
293
277
  }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { authConfigParam } from "./config-param";
4
+
5
+ describe("auth config parameters", () => {
6
+ it("binds JSON text that SQLite stores and reads back", async () => {
7
+ const sql = new SQL("sqlite://:memory:");
8
+ await sql`CREATE TABLE "auth_config" ("key" text PRIMARY KEY, "config" text NOT NULL DEFAULT '{}')`;
9
+ const config = { allowDelete: true, hibp: true };
10
+
11
+ await sql`INSERT INTO "auth_config" ("key", "config") VALUES (${"server"}, ${authConfigParam("sqlite", config)})`;
12
+
13
+ const [row] = await sql<Array<{ config: unknown }>>`SELECT "config" FROM "auth_config"`;
14
+ expect(typeof row?.config).toBe("string");
15
+ expect(JSON.parse(String(row?.config))).toEqual(config);
16
+ await sql.close();
17
+ });
18
+
19
+ it("keeps an object for PostgreSQL jsonb", () => {
20
+ const config = { clientId: "id" };
21
+ expect(authConfigParam("postgres", config)).toBe(config);
22
+ });
23
+ });
@@ -0,0 +1,10 @@
1
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
2
+
3
+ // PostgreSQL stores a jsonb object only when bound as an object (P-022), while
4
+ // SQLite keeps the column as text and rejects object parameters.
5
+ export function authConfigParam(
6
+ provider: DatabaseProviderName,
7
+ config: Record<string, unknown>,
8
+ ): Record<string, unknown> | string {
9
+ return provider === "sqlite" ? JSON.stringify(config) : config;
10
+ }
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { resetSessionExpirations } from "./session-expiry";
4
+
5
+ let open: SQL | undefined;
6
+
7
+ afterEach(async () => {
8
+ await open?.close();
9
+ open = undefined;
10
+ });
11
+
12
+ describe("session expiry reset on SQLite", () => {
13
+ it("moves active sessions to the new lifetime and leaves expired ones", async () => {
14
+ const sql = new SQL(":memory:");
15
+ open = sql;
16
+ // Better Auth stores SQLite dates as ISO 8601 text.
17
+ await sql`CREATE TABLE "session" ("id" text PRIMARY KEY, "expiresAt" date NOT NULL, "updatedAt" date NOT NULL)`;
18
+ const now = new Date("2026-10-15T10:00:00.000Z");
19
+ const active = new Date(now.getTime() + 3_600_000).toISOString();
20
+ const expired = new Date(now.getTime() - 1_000).toISOString();
21
+ await sql`INSERT INTO "session" VALUES (${"active"}, ${active}, ${active}), (${"expired"}, ${expired}, ${expired})`;
22
+
23
+ await resetSessionExpirations(sql, "sqlite", 1_800, now);
24
+
25
+ const rows = await sql<Array<{ id: string; expiresAt: string; updatedAt: string }>>`
26
+ SELECT "id", "expiresAt", "updatedAt" FROM "session" ORDER BY "id"
27
+ `;
28
+ expect(rows).toEqual([
29
+ { id: "active", expiresAt: "2026-10-15T10:30:00.000Z", updatedAt: now.toISOString() },
30
+ { id: "expired", expiresAt: expired, updatedAt: expired },
31
+ ]);
32
+ });
33
+ });
@@ -0,0 +1,29 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+
4
+ /** Give every active session the configured lifetime from now. */
5
+ export async function resetSessionExpirations(
6
+ sql: SQL,
7
+ provider: DatabaseProviderName,
8
+ lifetimeSeconds: number,
9
+ now: Date = new Date(),
10
+ ): Promise<void> {
11
+ if (provider === "sqlite") {
12
+ // Better Auth stores SQLite dates as ISO 8601 text, which sorts in time
13
+ // order. SQLite has no interval arithmetic.
14
+ const current = now.toISOString();
15
+ const expiresAt = new Date(now.getTime() + lifetimeSeconds * 1000).toISOString();
16
+ await sql`
17
+ UPDATE "session"
18
+ SET "expiresAt" = ${expiresAt}, "updatedAt" = ${current}
19
+ WHERE "expiresAt" > ${current}
20
+ `;
21
+ return;
22
+ }
23
+ await sql`
24
+ UPDATE "session"
25
+ SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
26
+ "updatedAt" = CURRENT_TIMESTAMP
27
+ WHERE "expiresAt" > CURRENT_TIMESTAMP
28
+ `;
29
+ }
@@ -27,3 +27,13 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
27
27
  };
28
28
 
29
29
  export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
30
+
31
+ /** Stored values override the shipped defaults; a missing row keeps the default. */
32
+ export function flagsFrom(settings: ReadonlyMap<string, string>): Record<FeatureFlag, boolean> {
33
+ const flags = { ...FLAG_DEFAULTS };
34
+ for (const flag of FEATURE_FLAGS) {
35
+ const stored = settings.get(flag);
36
+ if (stored !== undefined) flags[flag] = stored === "true";
37
+ }
38
+ return flags;
39
+ }
@@ -0,0 +1,81 @@
1
+ import { afterEach, beforeEach, describe, expect, setSystemTime, test } from "bun:test";
2
+ import type { SQL } from "bun";
3
+ import { createConfigStore, type AppSettingRow, type ConfigVersions } from "@podosoft/podokit-auth";
4
+ import { SettingsService } from "./settings.service";
5
+
6
+ type Row = AppSettingRow & { updatedAt: number };
7
+
8
+ /** In-memory app_setting table with the same upsert semantics as the service's SQL. */
9
+ function table() {
10
+ const rows = new Map<string, Row>();
11
+ let clock = 1;
12
+ const sql = (async (_strings: TemplateStringsArray, ...values: unknown[]) => {
13
+ const [key, value] = values as [string, string];
14
+ rows.set(key, { key, value, updatedAt: clock++ });
15
+ return [];
16
+ }) as unknown as SQL;
17
+ const store = () => createConfigStore({
18
+ list: async () => [],
19
+ listSettings: async () => [...rows.values()].map(({ key, value }) => ({ key, value })),
20
+ versions: async (): Promise<ConfigVersions> => ({
21
+ authConfig: "0:",
22
+ appSettings: `${rows.size}:${Math.max(0, ...[...rows.values()].map((row) => row.updatedAt))}`,
23
+ }),
24
+ });
25
+ return { rows, sql, store };
26
+ }
27
+
28
+ describe("SettingsService", () => {
29
+ let now = Date.UTC(2026, 0, 1);
30
+ beforeEach(() => {
31
+ now = Date.UTC(2026, 0, 1);
32
+ setSystemTime(new Date(now));
33
+ });
34
+ afterEach(() => {
35
+ setSystemTime();
36
+ });
37
+
38
+ test("applies a local write at once and reports the change", async () => {
39
+ const { sql, store } = table();
40
+ let changes = 0;
41
+ const settings = new SettingsService(sql, store(), async () => {
42
+ changes += 1;
43
+ });
44
+ expect(await settings.getBool("require2fa")).toBe(false);
45
+ const flags = await settings.setMany({ require2fa: true });
46
+ expect(flags.require2fa).toBe(true);
47
+ expect(await settings.getBool("require2fa")).toBe(true);
48
+ expect(changes).toBe(1);
49
+ });
50
+
51
+ test("follows another replica's write within the snapshot TTL", async () => {
52
+ const { sql, store } = table();
53
+ const replicaA = new SettingsService(sql, store());
54
+ const replicaB = new SettingsService(sql, store());
55
+ expect(await replicaB.getBool("require2fa")).toBe(false);
56
+
57
+ await replicaA.setMany({ require2fa: true });
58
+ expect(await replicaB.getBool("require2fa")).toBe(false);
59
+ setSystemTime(new Date(now + 3_100));
60
+ expect(await replicaB.getBool("require2fa")).toBe(true);
61
+ expect((await replicaB.flags()).require2fa).toBe(true);
62
+ });
63
+
64
+ test("falls back to the shipped defaults when app_setting is unavailable", async () => {
65
+ const settings = new SettingsService(table().sql, createConfigStore({
66
+ list: async () => [],
67
+ listSettings: async () => [],
68
+ versions: async () => {
69
+ throw new Error('relation "app_setting" does not exist');
70
+ },
71
+ }));
72
+ expect(await settings.flags()).toMatchObject({ twoFactor: true, phoneNumber: false, require2fa: false });
73
+ });
74
+
75
+ test("returns a copy of the flags", async () => {
76
+ const { sql, store } = table();
77
+ const settings = new SettingsService(sql, store());
78
+ (await settings.flags()).require2fa = true;
79
+ expect(await settings.getBool("require2fa")).toBe(false);
80
+ });
81
+ });
@@ -1,53 +1,56 @@
1
1
  import type { SQL } from "bun";
2
- import { FLAG_DEFAULTS, FEATURE_FLAGS, type FeatureFlag } from "./flag-defaults";
2
+ import type { ConfigStore } from "@podosoft/podokit-auth";
3
+ import { FEATURE_FLAGS, flagsFrom, type FeatureFlag } from "./flag-defaults";
3
4
 
4
5
  export { FEATURE_FLAGS, type FeatureFlag };
5
6
 
6
- interface AppSettingRow {
7
- key: string;
8
- value: string;
9
- }
7
+ export type SettingsSnapshot = Pick<ConfigStore, "selectSettings" | "invalidate">;
10
8
 
11
- /** DB-backed feature flags with migration defaults as the source of truth. */
9
+ /** DB-backed feature flags with migration defaults as the source of truth. Reads
10
+ * come from the shared settings snapshot, so every replica follows a change
11
+ * within the snapshot TTL; a write here applies on this replica at once. */
12
12
  export class SettingsService {
13
- private cache = new Map<string, string>();
14
-
15
- constructor(private readonly sql: SQL) {}
13
+ private readonly current: () => Promise<Record<FeatureFlag, boolean>>;
14
+
15
+ constructor(
16
+ private readonly sql: SQL,
17
+ private readonly snapshot: SettingsSnapshot,
18
+ private readonly onChange: () => Promise<void> = async () => undefined,
19
+ ) {
20
+ this.current = snapshot.selectSettings(flagsFrom);
21
+ }
16
22
 
23
+ /** Reload the snapshot from the database. */
17
24
  async refresh(): Promise<void> {
18
- try {
19
- const rows = await this.sql<AppSettingRow[]>`
20
- SELECT "key", "value" FROM "app_setting"
21
- `;
22
- this.cache = new Map(rows.map((row) => [row.key, row.value]));
23
- } catch {
24
- this.cache = new Map();
25
- }
25
+ this.snapshot.invalidate();
26
+ await this.current();
26
27
  }
27
28
 
28
- getBool(flag: FeatureFlag): boolean {
29
- const stored = this.cache.get(flag);
30
- return stored === undefined ? FLAG_DEFAULTS[flag] : stored === "true";
29
+ async getBool(flag: FeatureFlag): Promise<boolean> {
30
+ return (await this.current())[flag];
31
31
  }
32
32
 
33
- flags(): Record<FeatureFlag, boolean> {
34
- return Object.fromEntries(
35
- FEATURE_FLAGS.map((flag) => [flag, this.getBool(flag)]),
36
- ) as Record<FeatureFlag, boolean>;
33
+ async flags(): Promise<Record<FeatureFlag, boolean>> {
34
+ return { ...(await this.current()) };
37
35
  }
38
36
 
39
37
  async setMany(update: Partial<Record<FeatureFlag, boolean>>): Promise<Record<FeatureFlag, boolean>> {
40
- for (const flag of FEATURE_FLAGS) {
41
- const value = update[flag];
42
- if (typeof value !== "boolean") continue;
43
- await this.sql`
44
- INSERT INTO "app_setting" ("key", "value", "updatedAt")
45
- VALUES (${flag}, ${String(value)}, CURRENT_TIMESTAMP)
46
- ON CONFLICT ("key") DO UPDATE
47
- SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
48
- `;
38
+ try {
39
+ for (const flag of FEATURE_FLAGS) {
40
+ const value = update[flag];
41
+ if (typeof value !== "boolean") continue;
42
+ await this.sql`
43
+ INSERT INTO "app_setting" ("key", "value", "updatedAt")
44
+ VALUES (${flag}, ${String(value)}, CURRENT_TIMESTAMP)
45
+ ON CONFLICT ("key") DO UPDATE
46
+ SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
47
+ `;
48
+ }
49
+ } finally {
50
+ // Also after a failure: earlier rows of the update may have been written.
51
+ await this.refresh();
49
52
  }
50
- await this.refresh();
53
+ await this.onChange();
51
54
  return this.flags();
52
55
  }
53
56
  }
@@ -1,6 +1,8 @@
1
1
  # Bootstrap the initial administrator
2
2
 
3
3
  Use the generated database-backed command after the API build and migrations.
4
+ With `NODE_ENV=production` it loads the bundled `dist/bootstrap-admin-runtime.js`;
5
+ otherwise it runs the source entry. It supports PostgreSQL and SQLite.
4
6
  Do not create the first administrator through the public sign-up page.
5
7
 
6
8
  ## Set the persistent allowlist
@@ -7,8 +7,8 @@
7
7
  ".claude/skills/podokit-configure-auth/**"
8
8
  ],
9
9
  "dependencies": {
10
- "@podosoft/podokit-auth": "^0.4.0",
11
- "@podosoft/podokit-contracts": "^0.4.0",
10
+ "@podosoft/podokit-auth": "^0.5.0",
11
+ "@podosoft/podokit-contracts": "^0.5.0",
12
12
  "better-auth": "^1.7.7",
13
13
  "pg": "^8.12.0",
14
14
  "@better-auth/api-key": "^1.7.7",