@podosoft/podokit 1.1.6 → 1.2.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 (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. 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
  }
@@ -10,7 +10,9 @@ 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 { authConfigParam } from "./config-param";
14
16
 
15
17
  export type SocialProviderView = {
16
18
  id: string;
@@ -102,7 +104,10 @@ function booleanField(source: Record<string, unknown>, key: string, fallback: bo
102
104
  }
103
105
 
104
106
  export class AuthConfigService {
105
- constructor(private readonly sql: SQL) {}
107
+ constructor(
108
+ private readonly sql: SQL,
109
+ private readonly provider: DatabaseProviderName = "postgres",
110
+ ) {}
106
111
 
107
112
  private async rows(): Promise<AuthConfigRow[]> {
108
113
  return this.sql<AuthConfigRow[]>`
@@ -119,7 +124,7 @@ export class AuthConfigService {
119
124
  }
120
125
 
121
126
  private async upsert(row: AuthConfigRow): Promise<void> {
122
- const config = object(row.config);
127
+ const config = authConfigParam(this.provider, object(row.config));
123
128
  await this.sql`
124
129
  INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
125
130
  VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
@@ -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
+ }
@@ -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
  }
@@ -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",
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import type { ObjectBody, ObjectInfo, ObjectStore } from "@podosoft/podokit-runtime";
4
+ import { Elysia } from "elysia";
5
+ import type { RequestIdentity } from "../core/services";
6
+ import { filesRoutes } from "./files.module";
7
+
8
+ async function bytes(body: ObjectBody): Promise<Uint8Array> {
9
+ if (typeof body === "string") return Buffer.from(body);
10
+ if (body instanceof Uint8Array) return body;
11
+ const chunks: Uint8Array[] = [];
12
+ for await (const chunk of body) chunks.push(chunk);
13
+ return Buffer.concat(chunks);
14
+ }
15
+
16
+ function streamOf(value: Uint8Array): ReadableStream<Uint8Array> {
17
+ return new ReadableStream({
18
+ start(controller): void {
19
+ controller.enqueue(value);
20
+ controller.close();
21
+ },
22
+ });
23
+ }
24
+
25
+ function memoryStore(): ObjectStore {
26
+ const objects = new Map<string, Uint8Array>();
27
+ const info = (key: string): ObjectInfo | null => {
28
+ const value = objects.get(key);
29
+ return value
30
+ ? { key, size: value.byteLength, etag: `"${value.byteLength}"`, lastModified: new Date(0) }
31
+ : null;
32
+ };
33
+ return {
34
+ put: async (key, body, options) => {
35
+ const value = await bytes(body);
36
+ objects.set(key, value);
37
+ return { key, size: value.byteLength, ...options };
38
+ },
39
+ get: async (key) => {
40
+ const found = info(key);
41
+ const value = objects.get(key);
42
+ return found && value ? { ...found, body: streamOf(value) } : null;
43
+ },
44
+ stat: async (key) => info(key),
45
+ list: async (prefix) => ({ keys: [...objects.keys()].filter((key) => key.startsWith(prefix)).sort() }),
46
+ exists: async (key) => objects.has(key),
47
+ delete: async (key) => {
48
+ objects.delete(key);
49
+ },
50
+ getDownloadUrl: async (key) => `https://storage.example.com/${key}`,
51
+ close: () => undefined,
52
+ };
53
+ }
54
+
55
+ function caller(userId: string | undefined, admin = false): RequestIdentity {
56
+ return {
57
+ userId: async () => userId,
58
+ isAdmin: async () => admin,
59
+ roles: async () => (admin ? ["admin"] : []),
60
+ activeOrganizationId: async () => undefined,
61
+ };
62
+ }
63
+
64
+ interface Reply {
65
+ status: number;
66
+ body: Record<string, unknown>;
67
+ }
68
+
69
+ async function send(store: ObjectStore, identity: RequestIdentity, request: Request): Promise<Reply> {
70
+ const app = new Elysia()
71
+ .onError(({ error, set }) => {
72
+ if (!(error instanceof AppException)) return undefined;
73
+ set.status = error.statusCode;
74
+ return { code: error.code };
75
+ })
76
+ .use(filesRoutes(store, identity));
77
+ const response = await app.handle(request);
78
+ return { status: response.status, body: await response.json() as Record<string, unknown> };
79
+ }
80
+
81
+ async function upload(store: ObjectStore, identity: RequestIdentity): Promise<string> {
82
+ const form = new FormData();
83
+ form.append("file", new File(["hello"], "hello.txt", { type: "text/plain" }));
84
+ const reply = await send(store, identity, new Request("http://localhost/files", {
85
+ method: "POST",
86
+ body: form,
87
+ }));
88
+ expect(reply.status).toBe(201);
89
+ return String(reply.body.key);
90
+ }
91
+
92
+ function presign(store: ObjectStore, identity: RequestIdentity, key: string): Promise<Reply> {
93
+ return send(store, identity, new Request(`http://localhost/files/${encodeURIComponent(key)}/url`));
94
+ }
95
+
96
+ describe("file upload routes", () => {
97
+ it("stores uploads under the uploader and presigns them for the owner", async () => {
98
+ const store = memoryStore();
99
+ const key = await upload(store, caller("user_1"));
100
+
101
+ expect(key).toMatch(/^uploads\/user_1\/[0-9a-f-]{36}-hello\.txt$/);
102
+ expect(await presign(store, caller("user_1"), key)).toEqual({
103
+ status: 200,
104
+ body: { url: `https://storage.example.com/${key}` },
105
+ });
106
+ });
107
+
108
+ it("refuses another user's upload but lets an administrator presign it", async () => {
109
+ const store = memoryStore();
110
+ const key = await upload(store, caller("user_1"));
111
+
112
+ expect(await presign(store, caller("user_2"), key)).toEqual({
113
+ status: 403,
114
+ body: { code: "FILE_FORBIDDEN" },
115
+ });
116
+ expect((await presign(store, caller("admin_1", true), key)).status).toBe(200);
117
+ });
118
+
119
+ it("does not presign keys outside uploads", async () => {
120
+ const store = memoryStore();
121
+ for (const key of ["site/favicon", "profile-images/a.png", "demo/hello"]) {
122
+ expect(await presign(store, caller("admin_1", true), key)).toEqual({
123
+ status: 404,
124
+ body: { code: "FILE_NOT_FOUND" },
125
+ });
126
+ }
127
+ });
128
+
129
+ it("treats uploads from before ownership as administrator-only", async () => {
130
+ const store = memoryStore();
131
+ const legacy = "uploads/0b5f6b7e-8c1d-4d6e-9f00-112233445566-old.txt";
132
+
133
+ expect((await presign(store, caller("user_1"), legacy)).status).toBe(403);
134
+ expect((await presign(store, caller("admin_1", true), legacy)).status).toBe(200);
135
+ });
136
+ });
@@ -1,13 +1,19 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
1
2
  import { Elysia, t } from "elysia";
2
- import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
3
- import type { AppPlugin, PodokitModule } from "../core/services";
3
+ import { OBJECT_STORAGE, type ObjectStore } from "@podosoft/podokit-runtime";
4
+ import {
5
+ REQUEST_IDENTITY,
6
+ type AppPlugin,
7
+ type ElysiaPlugin,
8
+ type PodokitModule,
9
+ type RequestIdentity,
10
+ } from "../core/services";
11
+ import { uploadAccess, uploadKey } from "./upload-keys";
4
12
 
5
- const filesPlugin: AppPlugin = ({ services }) => {
6
- const storage = services.resolve(OBJECT_STORAGE);
13
+ export function filesRoutes(storage: ObjectStore, identity: RequestIdentity): ElysiaPlugin {
7
14
  return new Elysia({ name: "podokit.files" })
8
- .post("/files", async ({ body, set }) => {
9
- const safeName = body.file.name.replace(/[^\w.-]+/g, "_");
10
- const key = `uploads/${crypto.randomUUID()}-${safeName}`;
15
+ .post("/files", async ({ request, body, set }) => {
16
+ const key = uploadKey(await identity.userId(request), body.file.name);
11
17
  await storage.put(
12
18
  key,
13
19
  new Uint8Array(await body.file.arrayBuffer()),
@@ -19,13 +25,28 @@ const filesPlugin: AppPlugin = ({ services }) => {
19
25
  body: t.Object({ file: t.File() }),
20
26
  detail: { tags: ["files"], summary: "Upload a file" },
21
27
  })
22
- .get("/files/:key/url", async ({ params }) => ({
23
- url: await storage.getDownloadUrl(params.key),
24
- }), {
25
- params: t.Object({ key: t.String({ minLength: 1 }) }),
26
- detail: { tags: ["files"], summary: "Presign a file download" },
28
+ .get("/files/:key/url", async ({ request, params }) => {
29
+ const access = uploadAccess(params.key, {
30
+ userId: await identity.userId(request),
31
+ admin: await identity.isAdmin(request),
32
+ });
33
+ if (access === "not-found") throw new AppException("FILE_NOT_FOUND", "File not found", 404);
34
+ if (access === "forbidden") {
35
+ throw new AppException("FILE_FORBIDDEN", "You cannot access this file", 403);
36
+ }
37
+ return { url: await storage.getDownloadUrl(params.key) };
38
+ }, {
39
+ params: t.Object({ key: t.String({ minLength: 1, maxLength: 1024 }) }),
40
+ detail: {
41
+ tags: ["files"],
42
+ summary: "Presign a download of your upload",
43
+ description: "Pass the upload key URL-encoded as one path segment. Owners and administrators only.",
44
+ },
27
45
  });
28
- };
46
+ }
47
+
48
+ const filesPlugin: AppPlugin = ({ services }) =>
49
+ filesRoutes(services.resolve(OBJECT_STORAGE), services.resolve(REQUEST_IDENTITY));
29
50
 
30
51
  export const filesModule: PodokitModule = {
31
52
  name: "file-upload",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { uploadAccess, uploadKey } from "./upload-keys";
3
+
4
+ const id = "0b5f6b7e-8c1d-4d6e-9f00-112233445566";
5
+
6
+ describe("uploadKey", () => {
7
+ it("places a signed-in user's upload under their id", () => {
8
+ expect(uploadKey("user_1", "my photo.png", id)).toBe(`uploads/user_1/${id}-my_photo.png`);
9
+ });
10
+
11
+ it("keeps the unowned format when there is no signed-in user", () => {
12
+ expect(uploadKey(undefined, "a.txt", id)).toBe(`uploads/${id}-a.txt`);
13
+ });
14
+
15
+ it("keeps file names to one bounded segment", () => {
16
+ expect(uploadKey("u", "../../etc/passwd", id)).toBe(`uploads/u/${id}-.._.._etc_passwd`);
17
+ expect(uploadKey("u", `${"x".repeat(300)}.png`, id)).toBe(`uploads/u/${id}-${"x".repeat(124)}.png`);
18
+ });
19
+
20
+ it("refuses a user id that is not a safe key segment", () => {
21
+ expect(() => uploadKey("../admin", "a.txt", id)).toThrow("upload key segment");
22
+ });
23
+ });
24
+
25
+ describe("uploadAccess", () => {
26
+ const owner = { userId: "user_1", admin: false };
27
+ const other = { userId: "user_2", admin: false };
28
+ const admin = { userId: "admin_1", admin: true };
29
+ const anonymous = { userId: undefined, admin: false };
30
+
31
+ it("lets owners and administrators read owned uploads", () => {
32
+ const key = `uploads/user_1/${id}-a.txt`;
33
+ expect(uploadAccess(key, owner)).toBe("allowed");
34
+ expect(uploadAccess(key, admin)).toBe("allowed");
35
+ expect(uploadAccess(key, other)).toBe("forbidden");
36
+ expect(uploadAccess(key, anonymous)).toBe("forbidden");
37
+ });
38
+
39
+ it("keeps unowned uploads from signed-in users who are not administrators", () => {
40
+ const key = `uploads/${id}-a.txt`;
41
+ expect(uploadAccess(key, owner)).toBe("forbidden");
42
+ expect(uploadAccess(key, admin)).toBe("allowed");
43
+ expect(uploadAccess(key, anonymous)).toBe("allowed");
44
+ });
45
+
46
+ it("does not presign keys owned by other features", () => {
47
+ for (const key of [
48
+ "site/favicon",
49
+ "profile-images/a.png",
50
+ "demo/hello",
51
+ "uploads/",
52
+ "uploads/user_1/",
53
+ "uploads/../site/favicon",
54
+ "uploads/user_1/../user_2/a.txt",
55
+ "uploads/user_1/nested/a.txt",
56
+ ]) {
57
+ expect(uploadAccess(key, admin)).toBe("not-found");
58
+ }
59
+ });
60
+ });