@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
@@ -1,8 +1,10 @@
1
1
  import type { Capabilities } from "@podosoft/podokit-contracts";
2
2
  import { Elysia, t } from "elysia";
3
+ import { EVENTS } from "@podosoft/podokit-runtime";
3
4
  import {
4
5
  ACCESS_POLICY,
5
6
  DATABASE,
7
+ LOGGER,
6
8
  OPENAPI,
7
9
  REQUEST_GUARDS,
8
10
  REQUEST_IDENTITY,
@@ -17,10 +19,11 @@ import {
17
19
  import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
18
20
  import { authConfigStore } from "./auth-config-store";
19
21
  import { getAuth, authRuntime, primeAuth } from "./auth-provider";
20
- import { generateAuthOpenApiDocument } from "./auth.openapi";
22
+ import { cachedAuthOpenApiDocuments } from "./auth.openapi";
21
23
  import { ROLE_NAMES } from "./permissions";
22
- import { AuthService } from "./auth.service";
24
+ import { AuthService, createRequestIdentity } from "./auth.service";
23
25
  import { configureAuthQueryDatabase } from "./db";
26
+ import { configEvents, publishConfigChanged } from "./config-events";
24
27
  import { setMailConfigStore } from "../mail/mailer";
25
28
 
26
29
  export const AUTH = Symbol("auth") as ServiceKey<AuthService>;
@@ -69,12 +72,14 @@ const accountPlugin: AppPlugin = ({ services }) => {
69
72
  }, {
70
73
  detail: { tags: ["account"], summary: "Get the current account" },
71
74
  })
72
- .get("/account/require-2fa", () => ({ require2fa: settings.getBool("require2fa") }), {
75
+ .get("/account/require-2fa", async () => ({ require2fa: await settings.getBool("require2fa") }), {
73
76
  detail: { tags: ["account"], summary: "Get the two-factor enrolment policy" },
74
77
  })
75
78
  .get("/account/capabilities", async (): Promise<Capabilities> => {
76
- const flags = settings.flags();
77
- const snapshot = await authConfigStore.capabilitiesSnapshot();
79
+ const [flags, snapshot] = await Promise.all([
80
+ settings.flags(),
81
+ authConfigStore.capabilitiesSnapshot(),
82
+ ]);
78
83
  return {
79
84
  twoFactor: flags.twoFactor,
80
85
  magicLink: flags.magicLink,
@@ -86,6 +91,7 @@ const accountPlugin: AppPlugin = ({ services }) => {
86
91
  passkey: flags.passkey,
87
92
  organization: flags.organization,
88
93
  oidcProvider: flags.oidcProvider,
94
+ require2fa: flags.require2fa,
89
95
  providers: snapshot.providers,
90
96
  deleteAccount: snapshot.allowDelete,
91
97
  emailVerification: snapshot.requireEmailVerification,
@@ -151,26 +157,35 @@ export const authModule: PodokitModule = {
151
157
  configureAuthQueryDatabase(database.sql);
152
158
  setMailConfigStore(authConfigStore);
153
159
  const accessPolicy = services.resolve(ACCESS_POLICY);
154
- const settings = new SettingsService(database.sql);
160
+ const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
155
161
  const authService = new AuthService(settings, accessPolicy);
156
162
  services.register(SETTINGS, settings);
157
- services.register(AUTH_CONFIG, new AuthConfigService(database.sql));
163
+ services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
158
164
  services.register(AUTH, authService, () => authService.close());
159
165
  services.onStart(async () => {
160
- await settings.refresh();
161
166
  await primeAuth();
167
+ // Optional: with an events provider, other replicas' settings writes apply
168
+ // at once instead of at the next version probe.
169
+ const events = services.tryResolve(EVENTS);
170
+ if (!events) return;
171
+ try {
172
+ await configEvents.listen(events, () => authConfigStore.invalidate());
173
+ } catch (error) {
174
+ services.resolve(LOGGER).warn(
175
+ { reason: error instanceof Error ? error.message : String(error) },
176
+ "Settings change events are unavailable; replicas converge on the settings TTL",
177
+ );
178
+ }
162
179
  });
163
- services.override(REQUEST_IDENTITY, {
164
- userId: async (request) => (await authService.session(request))?.user.id,
165
- });
180
+ services.override(REQUEST_IDENTITY, createRequestIdentity(authService));
166
181
  accessPolicy.register("*", "/api/auth", "public");
167
182
  accessPolicy.register("*", "/api/auth/*", "public");
168
183
  accessPolicy.register("GET", "/account/capabilities", "public");
184
+ const authOpenApiDocument = cachedAuthOpenApiDocuments();
169
185
  services.resolve(OPENAPI).register("better-auth", async () => {
170
186
  await primeAuth();
171
- const document = await generateAuthOpenApiDocument(getAuth().api);
172
187
  return {
173
- document,
188
+ document: await authOpenApiDocument(getAuth()),
174
189
  pathPrefix: "/api/auth",
175
190
  };
176
191
  });
@@ -1,5 +1,23 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { generateAuthOpenApiDocument, isAuthOpenApiApi } from "./auth.openapi";
1
+ import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import {
3
+ cachedAuthOpenApiDocuments,
4
+ generateAuthOpenApiDocument,
5
+ isAuthOpenApiApi,
6
+ } from "./auth.openapi";
7
+
8
+ type GenerateSchema = () => Promise<Record<string, unknown>>;
9
+
10
+ function authInstance(version: string): { api: { generateOpenAPISchema: Mock<GenerateSchema> } } {
11
+ return {
12
+ api: {
13
+ generateOpenAPISchema: mock<GenerateSchema>(async () => ({
14
+ openapi: "3.1.0",
15
+ info: { version },
16
+ paths: {},
17
+ })),
18
+ },
19
+ };
20
+ }
3
21
 
4
22
  describe("Better Auth OpenAPI integration", () => {
5
23
  it("accepts the generated OpenAPI API without weakening the auth type", async () => {
@@ -17,4 +35,40 @@ describe("Better Auth OpenAPI integration", () => {
17
35
  "invalid OpenAPI document",
18
36
  );
19
37
  });
38
+
39
+ it("reuses the generated document for the same auth instance", async () => {
40
+ const documents = cachedAuthOpenApiDocuments();
41
+ const auth = authInstance("1");
42
+
43
+ const first = await documents(auth);
44
+ const second = await documents(auth);
45
+
46
+ expect(second).toBe(first);
47
+ expect(auth.api.generateOpenAPISchema).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ it("generates a fresh document after the auth instance is rebuilt", async () => {
51
+ const documents = cachedAuthOpenApiDocuments();
52
+ const original = authInstance("1");
53
+ const rebuilt = authInstance("2");
54
+
55
+ const before = await documents(original);
56
+ const after = await documents(rebuilt);
57
+
58
+ expect(after).not.toBe(before);
59
+ expect(after).toMatchObject({ info: { version: "2" } });
60
+ expect(rebuilt.api.generateOpenAPISchema).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("retries after a rejected generation instead of caching the failure", async () => {
64
+ const documents = cachedAuthOpenApiDocuments();
65
+ const auth = authInstance("1");
66
+ auth.api.generateOpenAPISchema.mockImplementationOnce(async () => {
67
+ throw new Error("plugin endpoints unavailable");
68
+ });
69
+
70
+ await expect(documents(auth)).rejects.toThrow("plugin endpoints unavailable");
71
+ await expect(documents(auth)).resolves.toMatchObject({ openapi: "3.1.0" });
72
+ expect(auth.api.generateOpenAPISchema).toHaveBeenCalledTimes(2);
73
+ });
20
74
  });
@@ -20,3 +20,25 @@ export async function generateAuthOpenApiDocument(api: unknown): Promise<Record<
20
20
  }
21
21
  return document;
22
22
  }
23
+
24
+ export interface AuthWithApi {
25
+ api: unknown;
26
+ }
27
+
28
+ export type AuthOpenApiDocuments = (auth: AuthWithApi) => Promise<Record<string, unknown>>;
29
+
30
+ /** Generating the schema rebuilds every plugin's endpoints, so reuse it until the
31
+ * auth instance is rebuilt. A rejected generation is dropped so the next call retries. */
32
+ export function cachedAuthOpenApiDocuments(): AuthOpenApiDocuments {
33
+ const documents = new WeakMap<AuthWithApi, Promise<Record<string, unknown>>>();
34
+ return (auth) => {
35
+ const cached = documents.get(auth);
36
+ if (cached) return cached;
37
+ const document = generateAuthOpenApiDocument(auth.api);
38
+ documents.set(auth, document);
39
+ document.catch(() => {
40
+ if (documents.get(auth) === document) documents.delete(auth);
41
+ });
42
+ return document;
43
+ };
44
+ }
@@ -1,14 +1,27 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { AccessPolicy } from "../core/services";
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { AccessPolicy, type RequestIdentity } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
- import { AuthService, rolesOf, type AuthSession } from "./auth.service";
4
+ import {
5
+ AuthService,
6
+ createRequestIdentity,
7
+ rolesOf,
8
+ type AuthSession,
9
+ type SessionResolver,
10
+ } from "./auth.service";
5
11
 
6
12
  function settings(require2fa: boolean): SettingsService {
7
13
  return { getBool: () => require2fa } as unknown as SettingsService;
8
14
  }
9
15
 
10
- function session(overrides: Partial<AuthSession["user"]> = {}): AuthSession {
11
- return { user: { id: "user-1", ...overrides }, session: {} };
16
+ function session(
17
+ overrides: Partial<AuthSession["user"]> = {},
18
+ sessionFields: Record<string, unknown> = {},
19
+ ): AuthSession {
20
+ return { user: { id: "user-1", ...overrides }, session: sessionFields };
21
+ }
22
+
23
+ function identityFor(resolver: SessionResolver): RequestIdentity {
24
+ return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
12
25
  }
13
26
 
14
27
  describe("AuthService", () => {
@@ -50,3 +63,66 @@ describe("AuthService", () => {
50
63
  .rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
51
64
  });
52
65
  });
66
+
67
+ describe("createRequestIdentity", () => {
68
+ const request = (): Request => new Request("http://localhost/events");
69
+
70
+ test("treats a request without a session as anonymous", async () => {
71
+ const identity = identityFor(async () => null);
72
+ const anonymous = request();
73
+ expect(await identity.userId(anonymous)).toBeUndefined();
74
+ expect(await identity.isAdmin(anonymous)).toBe(false);
75
+ expect(await identity.roles(anonymous)).toEqual([]);
76
+ expect(await identity.activeOrganizationId(anonymous)).toBeUndefined();
77
+ });
78
+
79
+ test("reads every role from the comma-separated admin plugin format", async () => {
80
+ const identity = identityFor(async () => session({ role: "user,admin" }));
81
+ const signedIn = request();
82
+ expect(await identity.userId(signedIn)).toBe("user-1");
83
+ expect(await identity.roles(signedIn)).toEqual(["user", "admin"]);
84
+ expect(await identity.isAdmin(signedIn)).toBe(true);
85
+ });
86
+
87
+ test("reads roles stored as an array", async () => {
88
+ const identity = identityFor(async () => session({ role: ["moderator"] }));
89
+ const signedIn = request();
90
+ expect(await identity.roles(signedIn)).toEqual(["moderator"]);
91
+ expect(await identity.isAdmin(signedIn)).toBe(false);
92
+ });
93
+
94
+ test("returns no roles when the user has none", async () => {
95
+ const identity = identityFor(async () => session({ role: null }));
96
+ expect(await identity.roles(request())).toEqual([]);
97
+ });
98
+
99
+ test("reads the active organization from the session", async () => {
100
+ const active = identityFor(async () => session({}, { activeOrganizationId: "org-1" }));
101
+ expect(await active.activeOrganizationId(request())).toBe("org-1");
102
+
103
+ const cleared = identityFor(async () => session({}, { activeOrganizationId: null }));
104
+ expect(await cleared.activeOrganizationId(request())).toBeUndefined();
105
+
106
+ const missing = identityFor(async () => session());
107
+ expect(await missing.activeOrganizationId(request())).toBeUndefined();
108
+ });
109
+
110
+ test("resolves the session once per request across all questions", async () => {
111
+ const resolver = mock(async (_headers: Headers): Promise<AuthSession | null> =>
112
+ session({ role: "admin" }, { activeOrganizationId: "org-1" }));
113
+ const identity = identityFor(resolver);
114
+ const first = request();
115
+
116
+ await Promise.all([
117
+ identity.userId(first),
118
+ identity.isAdmin(first),
119
+ identity.roles(first),
120
+ identity.activeOrganizationId(first),
121
+ ]);
122
+ await identity.roles(first);
123
+ expect(resolver).toHaveBeenCalledTimes(1);
124
+
125
+ await identity.userId(request());
126
+ expect(resolver).toHaveBeenCalledTimes(2);
127
+ });
128
+ });
@@ -1,5 +1,5 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
- import type { AccessPolicy } from "../core/services";
2
+ import type { AccessPolicy, RequestIdentity } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import { getAuth, refreshIfStale } from "./auth-provider";
5
5
  import { closeAuthDatabase } from "./db";
@@ -28,6 +28,11 @@ export function rolesOf(user: AuthUser): string[] {
28
28
  return (user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
29
29
  }
30
30
 
31
+ function activeOrganizationOf(session: AuthSession): string | undefined {
32
+ const value = session.session.activeOrganizationId;
33
+ return typeof value === "string" && value !== "" ? value : undefined;
34
+ }
35
+
31
36
  export class AuthService {
32
37
  private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
33
38
 
@@ -62,7 +67,7 @@ export class AuthService {
62
67
  async guard(request: Request): Promise<void> {
63
68
  if (this.accessPolicy.resolve(request) !== "session") return;
64
69
  const session = await this.requireSession(request);
65
- if (!this.settings.getBool("require2fa") || session.user.twoFactorEnabled) return;
70
+ if (session.user.twoFactorEnabled || !(await this.settings.getBool("require2fa"))) return;
66
71
  const path = new URL(request.url).pathname;
67
72
  if (path === "/account/require-2fa" || path === "/account/settings") return;
68
73
  throw new AppException(
@@ -76,3 +81,22 @@ export class AuthService {
76
81
  await closeAuthDatabase();
77
82
  }
78
83
  }
84
+
85
+ /** Answers identity questions for other modules from the one session lookup memoized per request. */
86
+ export function createRequestIdentity(auth: AuthService): RequestIdentity {
87
+ return {
88
+ userId: async (request) => (await auth.session(request))?.user.id,
89
+ isAdmin: async (request) => {
90
+ const session = await auth.session(request);
91
+ return session !== null && rolesOf(session.user).includes("admin");
92
+ },
93
+ roles: async (request) => {
94
+ const session = await auth.session(request);
95
+ return session ? rolesOf(session.user) : [];
96
+ },
97
+ activeOrganizationId: async (request) => {
98
+ const session = await auth.session(request);
99
+ return session ? activeOrganizationOf(session) : undefined;
100
+ },
101
+ };
102
+ }
@@ -3,9 +3,10 @@ import { APIError } from "better-auth/api";
3
3
  import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer, openAPI } from "better-auth/plugins";
4
4
  import { actionEmail, sendMail } from "../mail/mailer";
5
5
  import { sendSms } from "../sms/sms";
6
- import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
6
+ import { assertUserCreationAllowed, createFeatureGate, readSignupOpen } from "./feature-gate";
7
+ import { featureFlags, isSignupOpen } from "./auth-config-store";
7
8
  import { orgAc, orgRoles } from "./org-permissions";
8
- import { authDatabase, readAppSettings } from "./db";
9
+ import { authDatabase } from "./db";
9
10
  import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
10
11
  import { apiKey } from "@better-auth/api-key";
11
12
  import { passkey } from "@better-auth/passkey";
@@ -47,7 +48,6 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
47
48
  // server-enforced toggles) apply without a restart. Everything below the injection
48
49
  // markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
49
50
  export function buildAuth(config: AuthConfig) {
50
- const isSignupOpen = createSignupOpenCheck(readAppSettings);
51
51
  const plugins: BetterAuthPlugin[] = [
52
52
  twoFactor(),
53
53
  magicLink({
@@ -112,9 +112,14 @@ export function buildAuth(config: AuthConfig) {
112
112
  // Accept the session token via `Authorization: Bearer <token>` (returned in the
113
113
  // `set-auth-token` response header on sign-in) instead of a cookie — for API and
114
114
  // mobile clients. Infrastructure plugin (always on), like jwt(); no UI toggle.
115
- bearer(),
115
+ // requireSignature ignores unsigned values, so API keys and OAuth access tokens
116
+ // sent as bearer credentials never cost a session lookup that cannot match.
117
+ bearer({ requireSignature: true }),
116
118
  // JWT/JWKS signing keys — required by the OAuth provider to sign id_tokens.
117
- jwt(),
119
+ // Without disableSettingJwtHeader, every session lookup (including server-side
120
+ // getSession calls) signs an unused `set-auth-jwt` header. Clients that need a
121
+ // JWT call GET /api/auth/token.
122
+ jwt({ disableSettingJwtHeader: true }),
118
123
  // Act as an OAuth2/OIDC identity provider: other apps can "Sign in with this app".
119
124
  // Register clients at /api/auth/oauth2/create-client; discovery at
120
125
  // /api/auth/.well-known/openid-configuration. See docs/modules.md.
@@ -137,7 +142,7 @@ export function buildAuth(config: AuthConfig) {
137
142
  before: async (user, context) => {
138
143
  const adminEmail = adminEmails.has(user.email.toLowerCase());
139
144
  const createdByAdmin = context?.path === "/admin/create-user";
140
- assertUserCreationAllowed(await isSignupOpen(), context?.path);
145
+ assertUserCreationAllowed(await readSignupOpen(isSignupOpen), context?.path);
141
146
  return {
142
147
  data: {
143
148
  ...user,
@@ -174,10 +179,11 @@ export function buildAuth(config: AuthConfig) {
174
179
  };
175
180
 
176
181
  // Request-time hooks. The feature gate turns the admin Settings toggles into a
177
- // real server boundary (disabled features 404). Other PodoKit modules add their
178
- // own hooks below the marker.
182
+ // real server boundary (disabled features 404). It reads the process-wide
183
+ // settings snapshot, so a rebuild does not start a new cache. Other PodoKit
184
+ // modules add their own hooks below the marker.
179
185
  const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
180
- before: createFeatureGate(readAppSettings, isSignupOpen),
186
+ before: createFeatureGate({ flags: featureFlags, signupOpen: isSignupOpen }),
181
187
  };
182
188
  // podokit:begin:auth-hooks
183
189
  // podokit:end:auth-hooks
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MemoryEventBus, type EventBus } from "@podosoft/podokit-runtime";
3
+ import { CONFIG_CHANGED_TOPIC, createConfigEvents } from "./config-events";
4
+
5
+ describe("settings change events", () => {
6
+ test("invalidates other processes but not the publisher", async () => {
7
+ const bus = new MemoryEventBus();
8
+ const replicaA = createConfigEvents();
9
+ const replicaB = createConfigEvents();
10
+ let changesA = 0;
11
+ let changesB = 0;
12
+ await replicaA.listen(bus, () => { changesA += 1; });
13
+ await replicaB.listen(bus, () => { changesB += 1; });
14
+
15
+ await replicaA.publish();
16
+ await Bun.sleep(0);
17
+ expect(changesA).toBe(0);
18
+ expect(changesB).toBe(1);
19
+ await bus.close();
20
+ });
21
+
22
+ test("does nothing without an events provider and tolerates a failing one", async () => {
23
+ await expect(createConfigEvents().publish()).resolves.toBeUndefined();
24
+
25
+ const failing: EventBus = {
26
+ ready: async () => undefined,
27
+ publish: async () => {
28
+ throw new Error("redis unavailable");
29
+ },
30
+ subscribe: async () => () => undefined,
31
+ close: () => undefined,
32
+ };
33
+ const events = createConfigEvents();
34
+ await events.listen(failing, () => undefined);
35
+ await expect(events.publish()).resolves.toBeUndefined();
36
+ expect(CONFIG_CHANGED_TOPIC).toBe("config.changed");
37
+ });
38
+ });
@@ -0,0 +1,43 @@
1
+ import type { EventBus } from "@podosoft/podokit-runtime";
2
+
3
+ export const CONFIG_CHANGED_TOPIC = "config.changed";
4
+
5
+ export interface ConfigEvents {
6
+ /** Run `onChange` when another process reports a settings write. */
7
+ listen(events: EventBus, onChange: () => void): Promise<void>;
8
+ /** Report a local settings write. Without a listening bus this does nothing. */
9
+ publish(): Promise<void>;
10
+ }
11
+
12
+ function originOf(event: unknown): unknown {
13
+ return event !== null && typeof event === "object" ? (event as { origin?: unknown }).origin : undefined;
14
+ }
15
+
16
+ /** Cross-replica invalidation over the optional events provider. Delivery is
17
+ * best effort: a lost message only delays convergence to the settings TTL. */
18
+ export function createConfigEvents(): ConfigEvents {
19
+ const origin = crypto.randomUUID();
20
+ let bus: EventBus | undefined;
21
+ return {
22
+ async listen(events, onChange): Promise<void> {
23
+ await events.subscribe(CONFIG_CHANGED_TOPIC, (event) => {
24
+ if (originOf(event) !== origin) onChange();
25
+ });
26
+ bus = events;
27
+ },
28
+ async publish(): Promise<void> {
29
+ if (!bus) return;
30
+ try {
31
+ await bus.publish(CONFIG_CHANGED_TOPIC, { origin });
32
+ } catch (err) {
33
+ console.warn("[auth-config] could not publish a settings change; other replicas follow within the TTL:", err instanceof Error ? err.message : err);
34
+ }
35
+ },
36
+ };
37
+ }
38
+
39
+ export const configEvents = createConfigEvents();
40
+
41
+ export function publishConfigChanged(): Promise<void> {
42
+ return configEvents.publish();
43
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { PROVIDERS } from "../config/providers";
5
+ import { postgresPool } from "./db";
6
+
7
+ function databaseProvider(): DatabaseProviderName {
8
+ return PROVIDERS.database;
9
+ }
10
+
11
+ describe("auth database pool", () => {
12
+ it("applies the pool limits from the environment", () => {
13
+ if (databaseProvider() !== "postgres") {
14
+ expect(postgresPool).toBeNull();
15
+ return;
16
+ }
17
+ const env = validateEnv(process.env);
18
+ expect(postgresPool?.options).toMatchObject({
19
+ max: env.DB_AUTH_POOL_MAX,
20
+ connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
21
+ idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
22
+ });
23
+ });
24
+ });
@@ -1,7 +1,7 @@
1
1
  import { Database as SqliteDatabase } from "bun:sqlite";
2
2
  import type { SQL } from "bun";
3
3
  import { Pool } from "pg";
4
- import type { AuthConfigRow } from "@podosoft/podokit-auth";
4
+ import type { AuthConfigRow, ConfigVersions } from "@podosoft/podokit-auth";
5
5
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
6
  import { validateEnv } from "../config/env.validation";
7
7
  import { PROVIDERS } from "../config/providers";
@@ -15,7 +15,12 @@ function databaseProvider(): DatabaseProviderName {
15
15
  const provider = databaseProvider();
16
16
 
17
17
  export const postgresPool = provider === "postgres"
18
- ? new Pool({ connectionString: url })
18
+ ? new Pool({
19
+ connectionString: url,
20
+ max: env.DB_AUTH_POOL_MAX,
21
+ connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
22
+ idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
23
+ })
19
24
  : null;
20
25
 
21
26
  export const sqliteDatabase = provider === "sqlite"
@@ -51,6 +56,30 @@ export async function readAuthConfig(): Promise<AuthConfigRow[]> {
51
56
  return rows.map((row) => ({ ...row, enabled: row.enabled === true || row.enabled === 1 }));
52
57
  }
53
58
 
59
+ type VersionRow = {
60
+ authConfigCount: number | string | bigint;
61
+ authConfigUpdatedAt: string | null;
62
+ appSettingCount: number | string | bigint;
63
+ appSettingUpdatedAt: string | null;
64
+ };
65
+
66
+ /** One cheap statement that changes whenever either settings table changes. The
67
+ * count catches deletes; the text cast keeps PostgreSQL's microsecond precision. */
68
+ export async function readConfigVersions(): Promise<ConfigVersions> {
69
+ const sql = queries();
70
+ const [row] = await sql<VersionRow[]>`
71
+ SELECT
72
+ (SELECT count(*) FROM "auth_config") AS "authConfigCount",
73
+ (SELECT CAST(max("updatedAt") AS TEXT) FROM "auth_config") AS "authConfigUpdatedAt",
74
+ (SELECT count(*) FROM "app_setting") AS "appSettingCount",
75
+ (SELECT CAST(max("updatedAt") AS TEXT) FROM "app_setting") AS "appSettingUpdatedAt"
76
+ `;
77
+ return {
78
+ authConfig: `${String(row?.authConfigCount ?? 0)}:${row?.authConfigUpdatedAt ?? ""}`,
79
+ appSettings: `${String(row?.appSettingCount ?? 0)}:${row?.appSettingUpdatedAt ?? ""}`,
80
+ };
81
+ }
82
+
54
83
  export async function closeAuthDatabase(): Promise<void> {
55
84
  if (postgresPool) await postgresPool.end();
56
85
  sqliteDatabase?.close();
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { FLAG_DEFAULTS, flagsFrom } from "../settings/flag-defaults";
3
+ import {
4
+ enforceFeatureGate,
5
+ PUBLIC_SIGNUP_DISABLED,
6
+ signupOpenFrom,
7
+ type FeatureFlags,
8
+ type FeatureGateSettings,
9
+ } from "./feature-gate";
10
+
11
+ function settings(flags: Partial<FeatureFlags>, signupOpen = true): FeatureGateSettings {
12
+ return {
13
+ flags: async () => ({ ...FLAG_DEFAULTS, ...flags }),
14
+ signupOpen: async () => signupOpen,
15
+ };
16
+ }
17
+
18
+ const failing: FeatureGateSettings = {
19
+ flags: async () => {
20
+ throw new Error('relation "app_setting" does not exist');
21
+ },
22
+ signupOpen: async () => {
23
+ throw new Error('relation "app_setting" does not exist');
24
+ },
25
+ };
26
+
27
+ describe("feature gate", () => {
28
+ test("rejects a disabled feature's endpoints with 404", async () => {
29
+ await expect(enforceFeatureGate("/sign-in/magic-link", settings({ magicLink: false })))
30
+ .rejects.toMatchObject({ statusCode: 404 });
31
+ await expect(enforceFeatureGate("/sign-in/magic-link", settings({ magicLink: true })))
32
+ .resolves.toBeUndefined();
33
+ await expect(enforceFeatureGate("/get-session", settings({ magicLink: false })))
34
+ .resolves.toBeUndefined();
35
+ });
36
+
37
+ test("closes public sign-up without blocking other paths", async () => {
38
+ await expect(enforceFeatureGate("/sign-up/email", settings({}, false)))
39
+ .rejects.toMatchObject({ body: { code: PUBLIC_SIGNUP_DISABLED } });
40
+ await expect(enforceFeatureGate("/sign-in/social", settings({}, false)))
41
+ .resolves.toBeUndefined();
42
+ });
43
+
44
+ test("fails open to defaults when the settings cannot be read", async () => {
45
+ await expect(enforceFeatureGate("/sign-up/email", failing)).resolves.toBeUndefined();
46
+ await expect(enforceFeatureGate("/sign-in/magic-link", failing)).resolves.toBeUndefined();
47
+ await expect(enforceFeatureGate("/phone-number/send-otp", failing))
48
+ .rejects.toMatchObject({ statusCode: 404 });
49
+ });
50
+
51
+ test("projects stored settings over the shipped defaults", () => {
52
+ const stored = new Map([["magicLink", "false"], ["phoneNumber", "true"], ["site.allowSignup", "false"]]);
53
+ expect(flagsFrom(stored)).toMatchObject({ magicLink: false, phoneNumber: true, twoFactor: true });
54
+ expect(flagsFrom(new Map())).toEqual(FLAG_DEFAULTS);
55
+ expect(signupOpenFrom(stored)).toBe(false);
56
+ expect(signupOpenFrom(new Map())).toBe(true);
57
+ });
58
+ });