@podosoft/podokit 1.3.0 → 1.4.1

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 (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -14,7 +14,8 @@
14
14
  "ownedGlobs": [
15
15
  "apps/web/src/lib/i18n/locales/**",
16
16
  "apps/web/src/lib/i18n/catalogs/app/**",
17
- "apps/web/src/lib/server/public-paths.ts"
17
+ "apps/web/src/lib/server/public-paths.ts",
18
+ "apps/web/src/lib/server/asset-paths.ts"
18
19
  ],
19
20
  "managedOverrides": [
20
21
  "apps/web/src/lib/components/ui/input/input.svelte",
@@ -118,7 +119,7 @@
118
119
  "file": "AGENTS.md",
119
120
  "marker": "<!-- podokit:end:agents-modules -->",
120
121
  "optional": true,
121
- "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
122
+ "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
122
123
  },
123
124
  {
124
125
  "file": "apps/api/src/app.ts",
@@ -34,7 +34,7 @@
34
34
  }
35
35
  ],
36
36
  "instructions": [
37
- "Run migrations to create the audit_logs table: {{apiRun}} migration:run{{apiWorkspaceArg}}",
37
+ "Run migrations to create the audit_logs table: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
38
38
  "Auth/admin actions are recorded by a Better Auth hook. Record semantic application events with AuditService.recordRequest() or recordAudit(); use recordAuditLater() only for high-volume, non-security events.",
39
39
  "List entries newest first (admin session required): curl -i -b cookies.txt 'localhost:5002/audit-logs?limit=50' and pass the X-Next-Cursor header back as ?cursor= for the next page"
40
40
  ],
@@ -0,0 +1,26 @@
1
+ import type { SQL } from "bun";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { refreshForRequest } from "./auth-provider";
5
+ import { configureAuthQueryDatabase } from "./db";
6
+
7
+ describe("refreshForRequest", () => {
8
+ it("stops waiting for a configuration refresh the database does not answer", async () => {
9
+ let queries = 0;
10
+ // Every query waits forever, like one on a paused server.
11
+ const silent = (): Promise<never> => {
12
+ queries += 1;
13
+ return new Promise(() => undefined);
14
+ };
15
+ configureAuthQueryDatabase(silent as unknown as SQL);
16
+
17
+ const waitMs = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
18
+ const started = Date.now();
19
+ await refreshForRequest();
20
+ const waited = Date.now() - started;
21
+
22
+ expect(queries).toBeGreaterThan(0);
23
+ expect(waited).toBeGreaterThanOrEqual(waitMs - 10);
24
+ expect(waited).toBeLessThan(waitMs + 500);
25
+ });
26
+ });
@@ -1,7 +1,12 @@
1
1
  import type { AuthConfig } from "@podosoft/podokit-auth";
2
+ import { validateEnv } from "../config/env.validation";
2
3
  import { auth as bootstrapAuth, buildAuth } from "./auth";
3
4
  import { authConfigStore } from "./auth-config-store";
4
5
  import { publishConfigChanged } from "./config-events";
6
+ import { answersWithin, checkAuthResponse } from "./database-outage";
7
+ import { authDatabaseReachable } from "./db";
8
+
9
+ const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
5
10
 
6
11
  // Runtime auth instance that rebuilds when the DB config changes, so admin edits
7
12
  // (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
@@ -52,10 +57,20 @@ export async function refreshAuthNow(): Promise<void> {
52
57
  await publishConfigChanged();
53
58
  }
54
59
 
60
+ /**
61
+ * Refreshes before a request is served. While PostgreSQL does not answer, the
62
+ * refresh lasts as long as a query may wait, and the request would wait for
63
+ * Better Auth's own queries after it. Past the readiness check timeout the
64
+ * current instance serves the request, and the refresh finishes in the background.
65
+ */
66
+ export async function refreshForRequest(): Promise<void> {
67
+ await answersWithin(refreshIfStale, REFRESH_WAIT_MS);
68
+ }
69
+
55
70
  // Stable handler reference the adapter captures once; redispatches per request.
56
71
  const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
57
- await refreshIfStale();
58
- return current.handler(request);
72
+ await refreshForRequest();
73
+ return checkAuthResponse(await current.handler(request), authDatabaseReachable);
59
74
  };
60
75
 
61
76
  export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
@@ -1,8 +1,9 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
2
  import type { AccessPolicy, RequestIdentity } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
- import { getAuth, refreshIfStale } from "./auth-provider";
5
- import { closeAuthDatabase } from "./db";
4
+ import { getAuth, refreshForRequest } from "./auth-provider";
5
+ import { rethrowAuthError } from "./database-outage";
6
+ import { authDatabaseReachable, closeAuthDatabase } from "./db";
6
7
 
7
8
  export interface AuthUser extends Record<string, unknown> {
8
9
  id: string;
@@ -18,9 +19,12 @@ export interface AuthSession {
18
19
  export type SessionResolver = (headers: Headers) => Promise<AuthSession | null>;
19
20
 
20
21
  function defaultSessionResolver(headers: Headers): Promise<AuthSession | null> {
21
- return refreshIfStale()
22
+ return refreshForRequest()
22
23
  .then(() => getAuth().api.getSession({ headers }))
23
- .then((session) => session as AuthSession | null);
24
+ .then(
25
+ (session) => session as AuthSession | null,
26
+ (error: unknown) => rethrowAuthError(error, authDatabaseReachable),
27
+ );
24
28
  }
25
29
 
26
30
  export function rolesOf(user: AuthUser): string[] {
@@ -14,7 +14,7 @@ import { oauthProvider } from "@better-auth/oauth-provider";
14
14
  import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
15
15
  import { runUserDeletedHandlers } from "./user-delete-handlers";
16
16
  import { authBaseUrl, authSecret } from "./auth-environment";
17
- import { authSchemaValidation } from "./schema-validation";
17
+ import { isMaintenanceCommand } from "./maintenance";
18
18
  // podokit:begin:auth-imports
19
19
  // podokit:end:auth-imports
20
20
 
@@ -233,7 +233,7 @@ export function buildAuth(config: AuthConfig) {
233
233
  // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
234
234
  ipAddressHeaders: ["x-forwarded-for"],
235
235
  },
236
- ...(authSchemaValidation() ? {} : { database: { validateSchema: false } }),
236
+ ...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
237
237
  },
238
238
  user: {
239
239
  additionalFields: {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { APIError } from "better-auth/api";
3
+ import { Pool } from "pg";
4
+ import {
5
+ answersWithin,
6
+ checkAuthResponse,
7
+ rethrowAuthError,
8
+ sharedProbe,
9
+ } from "./database-outage";
10
+
11
+ const down = mock(async () => false);
12
+ const up = mock(async () => true);
13
+
14
+ async function rejection(promise: Promise<unknown>): Promise<unknown> {
15
+ return promise.then(() => undefined, (error: unknown) => error);
16
+ }
17
+
18
+ describe("checkAuthResponse", () => {
19
+ it("passes answers below 500 on without asking the database", async () => {
20
+ const probe = mock(async () => false);
21
+ for (const status of [200, 401, 403, 429]) {
22
+ const response = new Response(null, { status });
23
+ await expect(checkAuthResponse(response, probe)).resolves.toBe(response);
24
+ }
25
+ expect(probe).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it("keeps a server error while the database answers", async () => {
29
+ const response = new Response(null, { status: 500 });
30
+ await expect(checkAuthResponse(response, up)).resolves.toBe(response);
31
+ });
32
+
33
+ it("reports a server error during a database outage as DATABASE_UNAVAILABLE", async () => {
34
+ const error = await rejection(checkAuthResponse(new Response(null, { status: 500 }), down));
35
+ expect(error).toMatchObject({ code: "DATABASE_UNAVAILABLE", statusCode: 503 });
36
+ });
37
+ });
38
+
39
+ describe("rethrowAuthError", () => {
40
+ it("rethrows client errors without asking the database", async () => {
41
+ const probe = mock(async () => false);
42
+ const unauthorized = new APIError("UNAUTHORIZED", { message: "Invalid session" });
43
+ await expect(rejection(rethrowAuthError(unauthorized, probe))).resolves.toBe(unauthorized);
44
+ expect(probe).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it("reports a server error during a database outage as DATABASE_UNAVAILABLE", async () => {
48
+ const failed = APIError.from("INTERNAL_SERVER_ERROR", { message: "Failed to get session", code: "FAILED_TO_GET_SESSION" });
49
+ const error = await rejection(rethrowAuthError(failed, down));
50
+ expect(error).toMatchObject({ code: "DATABASE_UNAVAILABLE", statusCode: 503 });
51
+ expect(await rejection(rethrowAuthError(new Error("socket hang up"), down)))
52
+ .toMatchObject({ code: "DATABASE_UNAVAILABLE" });
53
+ });
54
+
55
+ it("rethrows other server errors unchanged while the database answers", async () => {
56
+ const failure = new Error("Unexpected");
57
+ await expect(rejection(rethrowAuthError(failure, up))).resolves.toBe(failure);
58
+ });
59
+ });
60
+
61
+ describe("answersWithin", () => {
62
+ it("reports success and failure of the query", async () => {
63
+ await expect(answersWithin(async () => [{ ok: 1 }], 100)).resolves.toBe(true);
64
+ await expect(answersWithin(async () => { throw new Error("refused"); }, 100)).resolves.toBe(false);
65
+ });
66
+
67
+ it("gives up on a query that does not answer", async () => {
68
+ const started = Date.now();
69
+ await expect(answersWithin(() => new Promise(() => undefined), 50)).resolves.toBe(false);
70
+ expect(Date.now() - started).toBeLessThan(1_000);
71
+ });
72
+
73
+ it("reports a PostgreSQL server that refuses or never answers as unreachable", async () => {
74
+ // Accepts the TCP connection and never answers, like a paused server.
75
+ const silent = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
76
+ const closed = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
77
+ closed.stop(true);
78
+ const pools = [silent.port, closed.port].map((port) =>
79
+ new Pool({
80
+ connectionString: `postgres://podokit:podokit@127.0.0.1:${port}/podokit`,
81
+ connectionTimeoutMillis: 2_000,
82
+ })
83
+ );
84
+ try {
85
+ for (const pool of pools) {
86
+ const started = Date.now();
87
+ await expect(answersWithin(() => pool.query("SELECT 1"), 200)).resolves.toBe(false);
88
+ expect(Date.now() - started).toBeLessThan(1_000);
89
+ }
90
+ } finally {
91
+ silent.stop(true);
92
+ await Promise.all(pools.map((pool) => pool.end().catch(() => undefined)));
93
+ }
94
+ });
95
+ });
96
+
97
+ describe("sharedProbe", () => {
98
+ it("lets concurrent callers share one probe and probes again afterwards", async () => {
99
+ let release: (value: boolean) => void = () => undefined;
100
+ const probe = mock(() => new Promise<boolean>((resolve) => { release = resolve; }));
101
+ const shared = sharedProbe(probe);
102
+
103
+ const first = shared();
104
+ const second = shared();
105
+ release(false);
106
+ await expect(Promise.all([first, second])).resolves.toEqual([false, false]);
107
+ expect(probe).toHaveBeenCalledTimes(1);
108
+
109
+ const third = shared();
110
+ release(true);
111
+ await expect(third).resolves.toBe(true);
112
+ expect(probe).toHaveBeenCalledTimes(2);
113
+ });
114
+ });
@@ -0,0 +1,50 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { APIError } from "better-auth/api";
3
+
4
+ /** Resolves whether the database answers now. */
5
+ export type DatabaseProbe = () => Promise<boolean>;
6
+
7
+ export function databaseUnavailable(): AppException {
8
+ return new AppException("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503);
9
+ }
10
+
11
+ /** Whether `query` succeeds within `timeoutMs`; a query still waiting then counts as failed. */
12
+ export async function answersWithin(query: () => Promise<unknown>, timeoutMs: number): Promise<boolean> {
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ const deadline = new Promise<boolean>((resolve) => {
15
+ timer = setTimeout(() => resolve(false), timeoutMs);
16
+ });
17
+ try {
18
+ return await Promise.race([query().then(() => true, () => false), deadline]);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+
24
+ /** Lets concurrent callers share one run of `probe`. */
25
+ export function sharedProbe(probe: DatabaseProbe): DatabaseProbe {
26
+ let running: Promise<boolean> | undefined;
27
+ return () => {
28
+ running ??= probe().finally(() => {
29
+ running = undefined;
30
+ });
31
+ return running;
32
+ };
33
+ }
34
+
35
+ // Better Auth answers a failed query with a bare 500, and its session lookup
36
+ // replaces the cause with INTERNAL_SERVER_ERROR. A server error is therefore
37
+ // checked against the database itself before it is reported.
38
+
39
+ /** Passes `response` on, unless it is a server error while the database does not answer. */
40
+ export async function checkAuthResponse(response: Response, reachable: DatabaseProbe): Promise<Response> {
41
+ if (response.status < 500 || (await reachable())) return response;
42
+ throw databaseUnavailable();
43
+ }
44
+
45
+ /** Rethrows `error`, as DATABASE_UNAVAILABLE when it is a server error while the database does not answer. */
46
+ export async function rethrowAuthError(error: unknown, reachable: DatabaseProbe): Promise<never> {
47
+ const clientError = error instanceof APIError && error.statusCode < 500;
48
+ if (!clientError && !(await reachable())) throw databaseUnavailable();
49
+ throw error;
50
+ }
@@ -3,22 +3,25 @@ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
3
  import { validateEnv } from "../config/env.validation";
4
4
  import { PROVIDERS } from "../config/providers";
5
5
  import { postgresPool } from "./db";
6
+ import { AuthPostgresPool } from "./postgres-pool";
6
7
 
7
8
  function databaseProvider(): DatabaseProviderName {
8
9
  return PROVIDERS.database;
9
10
  }
10
11
 
11
12
  describe("auth database pool", () => {
12
- it("applies the pool limits from the environment", () => {
13
+ it("applies the pool limits and the query timeout from the environment", () => {
13
14
  if (databaseProvider() !== "postgres") {
14
15
  expect(postgresPool).toBeNull();
15
16
  return;
16
17
  }
17
18
  const env = validateEnv(process.env);
19
+ expect(postgresPool).toBeInstanceOf(AuthPostgresPool);
18
20
  expect(postgresPool?.options).toMatchObject({
19
21
  max: env.DB_AUTH_POOL_MAX,
20
22
  connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
21
23
  idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
24
+ query_timeout: env.DB_QUERY_TIMEOUT_MS,
22
25
  });
23
26
  });
24
27
  });
@@ -1,11 +1,13 @@
1
1
  import { Database as SqliteDatabase } from "bun:sqlite";
2
2
  import type { SQL } from "bun";
3
- import { Pool } from "pg";
4
3
  import type { AuthConfigRow, ConfigVersions } from "@podosoft/podokit-auth";
5
4
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
5
  import { validateEnv } from "../config/env.validation";
7
6
  import { PROVIDERS } from "../config/providers";
8
7
  import { databaseUrl, sqliteDatabasePath } from "../database/database";
8
+ import { answersWithin, sharedProbe, type DatabaseProbe } from "./database-outage";
9
+ import { isMaintenanceCommand } from "./maintenance";
10
+ import { AuthPostgresPool } from "./postgres-pool";
9
11
 
10
12
  const env = validateEnv(process.env);
11
13
  const url = databaseUrl(env);
@@ -15,11 +17,12 @@ function databaseProvider(): DatabaseProviderName {
15
17
  const provider = databaseProvider();
16
18
 
17
19
  export const postgresPool = provider === "postgres"
18
- ? new Pool({
20
+ ? new AuthPostgresPool({
19
21
  connectionString: url,
20
22
  max: env.DB_AUTH_POOL_MAX,
21
23
  connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
22
24
  idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
25
+ ...(isMaintenanceCommand() ? {} : { query_timeout: env.DB_QUERY_TIMEOUT_MS }),
23
26
  })
24
27
  : null;
25
28
 
@@ -30,6 +33,12 @@ export const sqliteDatabase = provider === "sqlite"
30
33
  export const authDatabase = postgresPool ?? sqliteDatabase;
31
34
  if (!authDatabase) throw new Error("No authentication database is configured");
32
35
 
36
+ /** Whether the authentication database answers `SELECT 1` within the readiness check timeout. */
37
+ export const authDatabaseReachable: DatabaseProbe = sharedProbe(async () => {
38
+ if (!postgresPool) return true;
39
+ return answersWithin(() => postgresPool.query("SELECT 1"), env.READINESS_CHECK_TIMEOUT_MS);
40
+ });
41
+
33
42
  let queryDatabase: SQL | undefined;
34
43
 
35
44
  export function configureAuthQueryDatabase(sql: SQL): void {
@@ -0,0 +1,5 @@
1
+ // Imported first by maintenance commands, before ./auth creates the Better Auth
2
+ // instance and ./db opens its pool.
3
+ import { startMaintenanceCommand } from "./maintenance";
4
+
5
+ startMaintenanceCommand();
@@ -0,0 +1,14 @@
1
+ // Servers validate Better Auth's database schema at startup and bound every auth
2
+ // query with DB_QUERY_TIMEOUT_MS. Maintenance commands, such as the migration
3
+ // runner and the admin bootstrap, build the same instance before the schema they
4
+ // change exists or matches, and may run statements for longer, so they opt out
5
+ // for their process.
6
+ let maintenance = false;
7
+
8
+ export function startMaintenanceCommand(): void {
9
+ maintenance = true;
10
+ }
11
+
12
+ export function isMaintenanceCommand(): boolean {
13
+ return maintenance;
14
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { Socket } from "bun";
3
+ import type { PoolClient } from "pg";
4
+ import { AuthPostgresPool } from "./postgres-pool";
5
+
6
+ interface FakePostgres {
7
+ port: number;
8
+ /** While false, the server keeps every connection open and ignores queries, like a paused one. */
9
+ answering: boolean;
10
+ accepted: number;
11
+ closed: number;
12
+ stop: () => void;
13
+ }
14
+
15
+ function message(type: string, body: Buffer): Buffer {
16
+ const header = Buffer.alloc(5);
17
+ header.write(type, 0, "latin1");
18
+ header.writeInt32BE(body.length + 4, 1);
19
+ return Buffer.concat([header, body]);
20
+ }
21
+
22
+ const AUTHENTICATION_OK = message("R", Buffer.from([0, 0, 0, 0]));
23
+ const READY_FOR_QUERY = message("Z", Buffer.from("I"));
24
+ const SELECT_DONE = Buffer.concat([message("C", Buffer.from("SELECT 0\0")), READY_FOR_QUERY]);
25
+
26
+ /** Speaks enough of the PostgreSQL protocol for pg to connect and run simple queries. */
27
+ function fakePostgres(): FakePostgres {
28
+ const started = new WeakSet<Socket<undefined>>();
29
+ const server: FakePostgres = { port: 0, answering: true, accepted: 0, closed: 0, stop: () => undefined };
30
+ const listener = Bun.listen({
31
+ hostname: "127.0.0.1",
32
+ port: 0,
33
+ socket: {
34
+ open() {
35
+ server.accepted += 1;
36
+ },
37
+ close() {
38
+ server.closed += 1;
39
+ },
40
+ data(socket, data) {
41
+ if (!started.has(socket)) {
42
+ started.add(socket);
43
+ socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY]));
44
+ return;
45
+ }
46
+ if (server.answering && data[0] === "Q".charCodeAt(0)) socket.write(SELECT_DONE);
47
+ },
48
+ },
49
+ });
50
+ server.port = listener.port;
51
+ server.stop = () => listener.stop(true);
52
+ return server;
53
+ }
54
+
55
+ function poolFor(server: FakePostgres): AuthPostgresPool {
56
+ return new AuthPostgresPool({
57
+ host: "127.0.0.1",
58
+ port: server.port,
59
+ user: "podokit",
60
+ database: "podokit",
61
+ max: 1,
62
+ query_timeout: 50,
63
+ });
64
+ }
65
+
66
+ async function until(condition: () => boolean): Promise<void> {
67
+ const deadline = Date.now() + 2_000;
68
+ while (!condition()) {
69
+ if (Date.now() > deadline) throw new Error("Condition not met in time");
70
+ await new Promise((resolve) => setTimeout(resolve, 5));
71
+ }
72
+ }
73
+
74
+ describe("AuthPostgresPool", () => {
75
+ it("keeps a client whose queries were answered", async () => {
76
+ const server = fakePostgres();
77
+ const pool = poolFor(server);
78
+ try {
79
+ for (let round = 0; round < 2; round += 1) {
80
+ const client = await pool.connect();
81
+ await client.query("SELECT 1");
82
+ client.release();
83
+ }
84
+ expect(server.accepted).toBe(1);
85
+ expect(pool.idleCount).toBe(1);
86
+ } finally {
87
+ await pool.end();
88
+ server.stop();
89
+ }
90
+ });
91
+
92
+ it("discards a client released while its query still waits for the reply", async () => {
93
+ const server = fakePostgres();
94
+ const pool = poolFor(server);
95
+ try {
96
+ const client = await pool.connect();
97
+ server.answering = false;
98
+ await expect(client.query("SELECT 1")).rejects.toThrow("Query read timeout");
99
+ client.release();
100
+
101
+ expect(pool.totalCount).toBe(0);
102
+ await until(() => server.closed === 1);
103
+
104
+ server.answering = true;
105
+ const next = await pool.connect();
106
+ await next.query("SELECT 1");
107
+ next.release();
108
+ expect(server.accepted).toBe(2);
109
+ expect(pool.idleCount).toBe(1);
110
+ } finally {
111
+ await pool.end();
112
+ server.stop();
113
+ }
114
+ });
115
+
116
+ it("discards the client through the callback form and Pool.query", async () => {
117
+ const server = fakePostgres();
118
+ const pool = poolFor(server);
119
+ try {
120
+ server.answering = false;
121
+ await expect(pool.query("SELECT 1")).rejects.toThrow("Query read timeout");
122
+ expect(pool.totalCount).toBe(0);
123
+
124
+ const client = await new Promise<PoolClient>((resolve, reject) => {
125
+ pool.connect((error, connected) => (connected ? resolve(connected) : reject(error)));
126
+ });
127
+ await expect(client.query("SELECT 1")).rejects.toThrow("Query read timeout");
128
+ client.release();
129
+ expect(pool.totalCount).toBe(0);
130
+ await until(() => server.closed === 2);
131
+ } finally {
132
+ await pool.end();
133
+ server.stop();
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,40 @@
1
+ import { Pool, type PoolClient } from "pg";
2
+
3
+ type ConnectCallback = Parameters<Pool["connect"]>[0];
4
+
5
+ /** Whether `client` still waits for the reply to a query; pg sets `readyForQuery` once the server is ready for the next one. */
6
+ function awaitingReply(client: PoolClient): boolean {
7
+ return Reflect.get(client, "readyForQuery") === false;
8
+ }
9
+
10
+ function discardWhileAwaitingReply(client: PoolClient): PoolClient {
11
+ const release = client.release;
12
+ client.release = (error?: Error | boolean): void => {
13
+ release.call(
14
+ client,
15
+ error || (awaitingReply(client) ? new Error("Released while waiting for a query reply") : undefined),
16
+ );
17
+ };
18
+ return client;
19
+ }
20
+
21
+ /**
22
+ * pg fails a query after `query_timeout` but leaves the connection waiting for
23
+ * the reply. `Pool.query` discards that client, but Kysely, which Better Auth
24
+ * runs on, releases it as healthy, so the pool would hand it out again and
25
+ * every later query on it would queue behind the missing reply. This pool
26
+ * discards a client released while it still waits for a reply: pg closes its
27
+ * socket, and the next query gets a new connection.
28
+ */
29
+ export class AuthPostgresPool extends Pool {
30
+ override connect(): Promise<PoolClient>;
31
+ override connect(callback: ConnectCallback): void;
32
+ override connect(callback?: ConnectCallback): Promise<PoolClient> | void {
33
+ if (!callback) return super.connect().then(discardWhileAwaitingReply);
34
+ super.connect((error, client, done) => {
35
+ if (!client) return callback(error, client, done);
36
+ const guarded = discardWhileAwaitingReply(client);
37
+ callback(error, guarded, guarded.release);
38
+ });
39
+ }
40
+ }
@@ -1,6 +1,7 @@
1
1
  // Keep this first: Better Auth checks the schema when ./auth/auth creates its
2
- // instance, and the migrations below are what make that schema match.
3
- import "./auth/skip-schema-validation";
2
+ // instance, the migrations below are what make that schema match, and they may
3
+ // run longer than the auth pool's query timeout.
4
+ import "./auth/maintenance-command";
4
5
  import { getMigrations } from "better-auth/db/migration";
5
6
  import { auth } from "./auth/auth";
6
7
  import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
@@ -19,7 +19,6 @@
19
19
  "@types/pg": "^8.11.10"
20
20
  },
21
21
  "scripts": {
22
- "migrate:all": "bun scripts/run-migrations.mjs",
23
22
  "auth:configure": "bun scripts/configure-auth.mjs"
24
23
  },
25
24
  "env": [