@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
@@ -385,6 +385,8 @@ metadata:
385
385
  ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
386
386
  `;
387
387
  }
388
+ // Above the API's 800 ms per-check readiness bound; the Kubernetes default is 1 s.
389
+ const API_PROBE_TIMEOUT_SECONDS = 2;
388
390
  function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
389
391
  const name = `${profile.target.release}-${kind}`;
390
392
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
@@ -396,14 +398,17 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
396
398
  ? ` readinessProbe:
397
399
  httpGet: { path: /health/ready, port: 3000 }
398
400
  periodSeconds: 5
401
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
399
402
  livenessProbe:
400
403
  httpGet: { path: /health, port: 3000 }
401
404
  initialDelaySeconds: 20
402
405
  periodSeconds: 10
406
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
403
407
  startupProbe:
404
408
  httpGet: { path: /health, port: 3000 }
405
409
  failureThreshold: 30
406
410
  periodSeconds: 2
411
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
407
412
  `
408
413
  : ` readinessProbe:
409
414
  httpGet: { path: /, port: 3000 }
@@ -16,8 +16,8 @@
16
16
  "migration:generate": "bun run typeorm -- migration:generate"
17
17
  },
18
18
  "dependencies": {
19
- "@podosoft/podokit-contracts": "^0.4.0",
20
- "@podosoft/podokit-runtime": "^0.2.0",
19
+ "@podosoft/podokit-contracts": "^0.5.0",
20
+ "@podosoft/podokit-runtime": "^0.3.0",
21
21
  "@elysia/cors": "^1.4.2",
22
22
  "@elysia/openapi": "^1.4.15",
23
23
  "dotenv": "^16.4.5",
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import { cachedOpenApiDocument, type OpenApiDocumentLoader } from "./app";
3
+ import type { OpenApiContribution } from "./core/services";
4
+
5
+ function baseSpec(): Response {
6
+ return Response.json({ openapi: "3.1.0", paths: { "/health": { get: {} } } });
7
+ }
8
+
9
+ function source(options: {
10
+ contributions: () => OpenApiContribution[];
11
+ routeCount?: () => number;
12
+ base?: () => Response;
13
+ }): { base: Mock<() => Promise<Response>>; load: OpenApiDocumentLoader } {
14
+ const base = mock(async () => (options.base ?? baseSpec)());
15
+ const load = cachedOpenApiDocument({
16
+ routeCount: options.routeCount ?? (() => 1),
17
+ base,
18
+ contributions: async () => options.contributions(),
19
+ });
20
+ return { base, load };
21
+ }
22
+
23
+ describe("cachedOpenApiDocument", () => {
24
+ it("reuses the merged document while the inputs are unchanged", async () => {
25
+ const auth = { document: { paths: { "/sign-in": { post: {} } } }, pathPrefix: "/api/auth" };
26
+ const { base, load } = source({ contributions: () => [{ ...auth }] });
27
+
28
+ const first = await load();
29
+ const second = await load();
30
+
31
+ expect(second).toBe(first);
32
+ expect(first).toMatchObject({
33
+ paths: { "/health": { get: {} }, "/api/auth/sign-in": { post: {} } },
34
+ });
35
+ expect(base).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("rebuilds when a contributor returns a new document", async () => {
39
+ let auth: OpenApiContribution = {
40
+ document: { paths: { "/sign-in": { post: {} } } },
41
+ pathPrefix: "/api/auth",
42
+ };
43
+ const { base, load } = source({ contributions: () => [auth] });
44
+
45
+ await load();
46
+ auth = { document: { paths: { "/passkey": { post: {} } } }, pathPrefix: "/api/auth" };
47
+ const rebuilt = await load();
48
+
49
+ expect(rebuilt).toMatchObject({ paths: { "/api/auth/passkey": { post: {} } } });
50
+ expect(rebuilt).not.toHaveProperty(["paths", "/api/auth/sign-in"]);
51
+ expect(base).toHaveBeenCalledTimes(2);
52
+ });
53
+
54
+ it("rebuilds when routes are added", async () => {
55
+ let routes = 1;
56
+ const { base, load } = source({ contributions: () => [], routeCount: () => routes });
57
+
58
+ await load();
59
+ routes = 2;
60
+ await load();
61
+
62
+ expect(base).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ it("returns a failed base response without caching it", async () => {
66
+ let fail = true;
67
+ const { base, load } = source({
68
+ contributions: () => [],
69
+ base: () => fail ? Response.json({ success: false }, { status: 503 }) : baseSpec(),
70
+ });
71
+
72
+ const failed = await load();
73
+ fail = false;
74
+ const recovered = await load();
75
+
76
+ if (!(failed instanceof Response)) throw new Error("Expected the failed base response");
77
+ expect(failed.status).toBe(503);
78
+ expect(recovered).toMatchObject({ openapi: "3.1.0" });
79
+ expect(base).toHaveBeenCalledTimes(2);
80
+ });
81
+ });
@@ -8,9 +8,11 @@ import {
8
8
  appContext,
9
9
  createCoreServices,
10
10
  type AppPlugin,
11
+ type OpenApiContribution,
11
12
  type PodokitModule,
12
13
  type ServiceRegistry,
13
14
  } from "./core/services";
15
+ import { isDatabaseUnavailable } from "./database/database";
14
16
  import { healthPlugin } from "./health/health.plugin";
15
17
  // podokit:begin:imports
16
18
  // podokit:end:imports
@@ -52,6 +54,47 @@ function mergeOpenApi(
52
54
  return { ...base, paths, components, tags };
53
55
  }
54
56
 
57
+ export interface OpenApiDocumentSource {
58
+ routeCount: () => number;
59
+ base: () => Promise<Response>;
60
+ contributions: () => Promise<OpenApiContribution[]>;
61
+ }
62
+
63
+ export type OpenApiDocumentLoader = () => Promise<Record<string, unknown> | Response>;
64
+
65
+ function sameContributions(left: OpenApiContribution[], right: OpenApiContribution[]): boolean {
66
+ return left.length === right.length && left.every((contribution, index) =>
67
+ contribution.document === right[index]?.document &&
68
+ contribution.pathPrefix === right[index]?.pathPrefix
69
+ );
70
+ }
71
+
72
+ /** Contributors return the same document object while their inputs are unchanged,
73
+ * so identity plus the route count (the base spec's own cache key) identifies the
74
+ * merged result without a staleness window. */
75
+ export function cachedOpenApiDocument(source: OpenApiDocumentSource): OpenApiDocumentLoader {
76
+ let cached: {
77
+ routeCount: number;
78
+ contributions: OpenApiContribution[];
79
+ document: Record<string, unknown>;
80
+ } | undefined;
81
+ return async () => {
82
+ const contributions = await source.contributions();
83
+ const routeCount = source.routeCount();
84
+ if (
85
+ cached?.routeCount === routeCount &&
86
+ sameContributions(cached.contributions, contributions)
87
+ ) {
88
+ return cached.document;
89
+ }
90
+ const response = await source.base();
91
+ if (!response.ok) return response;
92
+ const document = mergeOpenApi(record(await response.json()), contributions);
93
+ cached = { routeCount, contributions, document };
94
+ return document;
95
+ };
96
+ }
97
+
55
98
  function errorEnvelope(
56
99
  code: string,
57
100
  message: string,
@@ -124,6 +167,10 @@ const buildApp = (options: CreateAppOptions = {}) => {
124
167
  set.status = error.statusCode;
125
168
  return errorEnvelope(error.code, error.message, error.statusCode, path);
126
169
  }
170
+ if (isDatabaseUnavailable(error)) {
171
+ set.status = 503;
172
+ return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
173
+ }
127
174
  const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
128
175
  set.status = statusCode;
129
176
  const publicCode = code === "NOT_FOUND"
@@ -144,11 +191,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
144
191
  ...modules.flatMap((module) => module.plugin ? [module.plugin] : []),
145
192
  ];
146
193
  for (const plugin of plugins) app.use(plugin(context));
147
- app.get("/api-docs-json", async () => {
148
- const response = await app.handle(new Request("http://localhost/api-docs-elysia-json"));
149
- const base = record(await response.json());
150
- return mergeOpenApi(base, await context.openapi.contributions());
151
- }, {
194
+ const openApiDocument = cachedOpenApiDocument({
195
+ routeCount: () => app.routes.length,
196
+ base: () => app.handle(new Request("http://localhost/api-docs-elysia-json")),
197
+ contributions: () => context.openapi.contributions(),
198
+ });
199
+ app.get("/api-docs-json", () => openApiDocument(), {
152
200
  detail: { hide: true },
153
201
  });
154
202
  app.onStop(() => { void services.close(); });
@@ -11,6 +11,12 @@ const schema = z.object({
11
11
  POSTGRES_USER: z.string().default("podokit"),
12
12
  POSTGRES_PASSWORD: z.string().default("podokit"),
13
13
  POSTGRES_DB: z.string().default("podokit"),
14
+ DB_POOL_MAX: z.coerce.number().int().positive().default(20),
15
+ DB_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
16
+ DB_IDLE_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(30000),
17
+ DB_AUTH_POOL_MAX: z.coerce.number().int().positive().default(10),
18
+ READINESS_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(800),
19
+ READINESS_CACHE_MS: z.coerce.number().int().nonnegative().default(2000),
14
20
  });
15
21
 
16
22
  export type AppEnv = z.infer<typeof schema>;
@@ -35,9 +35,11 @@ const MODULE_ROUTES: Record<string, string[]> = {
35
35
  "POST /account/profile-image",
36
36
  "DELETE /account/profile-image",
37
37
  "GET /profile-images/{fileName}",
38
+ "GET /admin/sessions",
39
+ "DELETE /admin/sessions/{id}",
38
40
  ],
39
41
  "api-key-auth": ["GET /machine/ping"],
40
- "audit-log": ["GET /audit-logs"],
42
+ "audit-log": ["GET /audit-logs", "GET /audit-logs/actions"],
41
43
  analytics: [
42
44
  "GET /analytics/config",
43
45
  "GET /admin/analytics/config",
@@ -70,6 +72,12 @@ const MODULE_ROUTES: Record<string, string[]> = {
70
72
  bullmq: ["POST /jobs", "GET /jobs/{id}"],
71
73
  "file-upload": ["POST /files", "GET /files/{key}/url"],
72
74
  "job-progress": ["POST /progress"],
75
+ "object-storage-local": [
76
+ "GET /files/content/*",
77
+ "PUT /storage/{key}",
78
+ "GET /storage/{key}",
79
+ "GET /storage/{key}/presigned",
80
+ ],
73
81
  "object-storage-s3": [
74
82
  "PUT /storage/{key}",
75
83
  "GET /storage/{key}",
@@ -1,11 +1,20 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { PROVIDERS } from "../config/providers";
2
5
  import {
3
6
  AccessPolicy,
7
+ createCoreServices,
4
8
  OpenApiRegistry,
9
+ REQUEST_IDENTITY,
5
10
  RequestGuardRegistry,
6
11
  ServiceRegistry,
7
12
  } from "./services";
8
13
 
14
+ function databaseProvider(): DatabaseProviderName {
15
+ return PROVIDERS.database;
16
+ }
17
+
9
18
  describe("ServiceRegistry", () => {
10
19
  test("awaits startup in registration order and starts only once", async () => {
11
20
  const registry = new ServiceRegistry();
@@ -88,3 +97,24 @@ describe("OpenApiRegistry", () => {
88
97
  expect(() => registry.register("auth", () => ({ document: {} }))).toThrow("already exists");
89
98
  });
90
99
  });
100
+
101
+ describe("RequestIdentity", () => {
102
+ test("treats every request as anonymous until an auth module overrides it", async () => {
103
+ const services = createCoreServices(validateEnv({
104
+ NODE_ENV: "test",
105
+ DATABASE_URL: databaseProvider() === "sqlite"
106
+ ? "sqlite://:memory:"
107
+ : "postgres://podokit:podokit@localhost:5432/podokit",
108
+ }));
109
+ const identity = services.resolve(REQUEST_IDENTITY);
110
+ const request = new Request("http://localhost/todos");
111
+ try {
112
+ expect(await identity.userId(request)).toBeUndefined();
113
+ expect(await identity.isAdmin(request)).toBe(false);
114
+ expect(await identity.roles(request)).toEqual([]);
115
+ expect(await identity.activeOrganizationId(request)).toBeUndefined();
116
+ } finally {
117
+ await services.close();
118
+ }
119
+ });
120
+ });
@@ -6,7 +6,7 @@ import {
6
6
  } from "@podosoft/podokit-runtime";
7
7
  import type { AppEnv } from "../config/env.validation";
8
8
  import { Database } from "../database/database";
9
- import { ReadinessService } from "../health/readiness.service";
9
+ import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.service";
10
10
 
11
11
  export type { ServiceKey } from "@podosoft/podokit-runtime";
12
12
 
@@ -124,6 +124,9 @@ export class RequestGuardRegistry {
124
124
 
125
125
  export interface RequestIdentity {
126
126
  userId(request: Request): Promise<string | undefined>;
127
+ isAdmin(request: Request): Promise<boolean>;
128
+ roles(request: Request): Promise<string[]>;
129
+ activeOrganizationId(request: Request): Promise<string | undefined>;
127
130
  }
128
131
 
129
132
  export interface OpenApiContribution {
@@ -177,7 +180,14 @@ export interface PodokitModule {
177
180
  export function createCoreServices(env: AppEnv): ServiceRegistry {
178
181
  const services = new ServiceRegistry();
179
182
  const database = new Database(env);
180
- const readiness = new ReadinessService();
183
+ const readiness = new ReadinessService({
184
+ timeoutMs: env.READINESS_CHECK_TIMEOUT_MS,
185
+ cacheMs: env.READINESS_CACHE_MS,
186
+ // Resolved on use: a module such as logging may replace the logger after this point.
187
+ onCheckFailure: (check, reason) => {
188
+ services.resolve(LOGGER).warn({ check, reason }, "Readiness check failed");
189
+ },
190
+ });
181
191
  const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
182
192
  const accessPolicy = new AccessPolicy();
183
193
  const requestGuards = new RequestGuardRegistry();
@@ -191,10 +201,16 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
191
201
  services.register(DATABASE, database, () => database.close());
192
202
  services.onStart(() => database.connect());
193
203
  services.register(READINESS, readiness);
204
+ readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
194
205
  services.register(LOGGER, logger);
195
206
  services.register(ACCESS_POLICY, accessPolicy);
196
207
  services.register(REQUEST_GUARDS, requestGuards);
197
- services.register(REQUEST_IDENTITY, { userId: async () => undefined });
208
+ services.register(REQUEST_IDENTITY, {
209
+ userId: async () => undefined,
210
+ isAdmin: async () => false,
211
+ roles: async () => [],
212
+ activeOrganizationId: async () => undefined,
213
+ });
198
214
  services.register(OPENAPI, openapiRegistry);
199
215
  return services;
200
216
  }
@@ -1,13 +1,20 @@
1
+ import { SQL } from "bun";
1
2
  import { describe, expect, it } from "bun:test";
2
3
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
- import type { AppEnv } from "../config/env.validation";
6
+ import { validateEnv } from "../config/env.validation";
6
7
  import { PROVIDERS } from "../config/providers";
7
- import { databaseUrl, sqliteDatabasePath } from "./database";
8
+ import {
9
+ Database,
10
+ databaseUrl,
11
+ isDatabaseUnavailable,
12
+ postgresPoolOptions,
13
+ sqliteDatabasePath,
14
+ } from "./database";
8
15
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
9
16
 
10
- const env: AppEnv = {
17
+ const env = validateEnv({
11
18
  NODE_ENV: "test",
12
19
  PORT: 5002,
13
20
  POSTGRES_HOST: "localhost",
@@ -15,12 +22,44 @@ const env: AppEnv = {
15
22
  POSTGRES_USER: "podokit",
16
23
  POSTGRES_PASSWORD: "podokit",
17
24
  POSTGRES_DB: "podokit",
18
- };
25
+ });
19
26
 
20
27
  function databaseProvider(): DatabaseProviderName {
21
28
  return PROVIDERS.database;
22
29
  }
23
30
 
31
+ interface ProbedSql {
32
+ sql: SQL;
33
+ probes: () => number;
34
+ setAvailable: (available: boolean) => void;
35
+ close: () => Promise<void>;
36
+ }
37
+
38
+ // Counts `SELECT 1` probes against an in-memory database and can simulate an outage.
39
+ function probedSql(): ProbedSql {
40
+ const target = new SQL(":memory:", { adapter: "sqlite" });
41
+ let probes = 0;
42
+ let available = true;
43
+ const sql = new Proxy(target, {
44
+ apply(query, thisArg: unknown, args: unknown[]): unknown {
45
+ const [strings] = args;
46
+ if (Array.isArray(strings) && strings.join("").trim() === "SELECT 1") {
47
+ probes += 1;
48
+ if (!available) return Promise.reject(new Error("database unavailable"));
49
+ }
50
+ return Reflect.apply(query, thisArg, args);
51
+ },
52
+ });
53
+ return {
54
+ sql,
55
+ probes: () => probes,
56
+ setAvailable: (value) => {
57
+ available = value;
58
+ },
59
+ close: () => target.close(),
60
+ };
61
+ }
62
+
24
63
  describe("database provider", () => {
25
64
  it("uses DATABASE_URL as the canonical connection setting", () => {
26
65
  const provider = databaseProvider();
@@ -56,3 +95,109 @@ describe("database provider", () => {
56
95
  }
57
96
  });
58
97
  });
98
+
99
+ describe("Database ping", () => {
100
+ it("queries the database on every ping", async () => {
101
+ const probe = probedSql();
102
+ const database = new Database(env, probe.sql);
103
+ try {
104
+ await database.ping();
105
+ const afterFirstPing = probe.probes();
106
+ await database.ping();
107
+ await database.ping();
108
+ expect(probe.probes()).toBe(afterFirstPing + 2);
109
+ } finally {
110
+ await probe.close();
111
+ }
112
+ });
113
+
114
+ it("reports an outage that starts after a successful connect", async () => {
115
+ const probe = probedSql();
116
+ const database = new Database(env, probe.sql);
117
+ try {
118
+ await database.connect();
119
+ probe.setAvailable(false);
120
+ await expect(database.ping()).rejects.toThrow("database unavailable");
121
+ probe.setAvailable(true);
122
+ await expect(database.ping()).resolves.toBeUndefined();
123
+ } finally {
124
+ await probe.close();
125
+ }
126
+ });
127
+
128
+ it("retries the connection after a failed attempt", async () => {
129
+ const probe = probedSql();
130
+ const database = new Database(env, probe.sql);
131
+ try {
132
+ probe.setAvailable(false);
133
+ await expect(database.connect()).rejects.toThrow("database unavailable");
134
+ probe.setAvailable(true);
135
+ await expect(database.ping()).resolves.toBeUndefined();
136
+ await expect(database.connect()).resolves.toBeUndefined();
137
+ } finally {
138
+ await probe.close();
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("PostgreSQL pool settings", () => {
144
+ it("bounds connections and converts the millisecond settings to Bun seconds", () => {
145
+ expect(postgresPoolOptions(env)).toEqual({ max: 20, connectionTimeout: 10, idleTimeout: 30 });
146
+ expect(env.DB_AUTH_POOL_MAX).toBe(10);
147
+ expect(
148
+ postgresPoolOptions(validateEnv({ DB_POOL_MAX: "5", DB_CONNECT_TIMEOUT_MS: "2500", DB_IDLE_TIMEOUT_MS: "0" })),
149
+ ).toEqual({ max: 5, connectionTimeout: 2.5, idleTimeout: 0 });
150
+ });
151
+
152
+ it("rejects pool settings that would disable the limits", () => {
153
+ expect(() => validateEnv({ DB_POOL_MAX: "0" })).toThrow("DB_POOL_MAX");
154
+ expect(() => validateEnv({ DB_AUTH_POOL_MAX: "-1" })).toThrow("DB_AUTH_POOL_MAX");
155
+ expect(() => validateEnv({ DB_CONNECT_TIMEOUT_MS: "0" })).toThrow("DB_CONNECT_TIMEOUT_MS");
156
+ });
157
+ });
158
+
159
+ describe("isDatabaseUnavailable", () => {
160
+ it("recognizes Bun SQL connection failures", () => {
161
+ for (const code of [
162
+ "ERR_POSTGRES_CONNECTION_TIMEOUT",
163
+ "ERR_POSTGRES_CONNECTION_REFUSED",
164
+ "ERR_POSTGRES_CONNECTION_FAILED",
165
+ "ERR_POSTGRES_CONNECTION_CLOSED",
166
+ ]) {
167
+ expect(isDatabaseUnavailable(new SQL.PostgresError("Connection failed", { code }))).toBe(true);
168
+ }
169
+ });
170
+
171
+ it("recognizes pg pool and connection timeouts, also when wrapped", () => {
172
+ const acquire = new Error("timeout exceeded when trying to connect");
173
+ const connect = new Error("Connection terminated due to connection timeout");
174
+ expect(isDatabaseUnavailable(acquire)).toBe(true);
175
+ expect(isDatabaseUnavailable(connect)).toBe(true);
176
+ expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
177
+ });
178
+
179
+ it("leaves query and application errors alone", () => {
180
+ expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
181
+ .toBe(false);
182
+ expect(
183
+ isDatabaseUnavailable(new SQL.PostgresError("duplicate key", { code: "ERR_POSTGRES_SERVER_ERROR", errno: "23505" })),
184
+ ).toBe(false);
185
+ expect(isDatabaseUnavailable(new Error("timeout"))).toBe(false);
186
+ expect(isDatabaseUnavailable("ERR_POSTGRES_CONNECTION_TIMEOUT")).toBe(false);
187
+ expect(isDatabaseUnavailable(undefined)).toBe(false);
188
+ });
189
+
190
+ it("recognizes a real Bun SQL connection timeout", async () => {
191
+ // Accepts the TCP connection and never answers, like an overloaded server.
192
+ const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
193
+ const sql = new SQL(`postgres://podokit:podokit@127.0.0.1:${server.port}/podokit`, { connectionTimeout: 0.2 });
194
+ try {
195
+ const error: unknown = await sql`SELECT 1`.then(() => undefined, (reason: unknown) => reason);
196
+ expect(error).toBeInstanceOf(SQL.PostgresError);
197
+ expect(isDatabaseUnavailable(error)).toBe(true);
198
+ } finally {
199
+ await sql.close();
200
+ server.stop(true);
201
+ }
202
+ });
203
+ });
@@ -47,35 +47,88 @@ export function sqliteDatabasePath(url: string): string {
47
47
  return path;
48
48
  }
49
49
 
50
+ export interface PostgresPoolOptions {
51
+ max: number;
52
+ connectionTimeout: number;
53
+ idleTimeout: number;
54
+ }
55
+
56
+ /** Bun SQL takes these timeouts in seconds; the environment sets milliseconds. */
57
+ export function postgresPoolOptions(env: AppEnv): PostgresPoolOptions {
58
+ return {
59
+ max: env.DB_POOL_MAX,
60
+ connectionTimeout: env.DB_CONNECT_TIMEOUT_MS / 1000,
61
+ idleTimeout: env.DB_IDLE_TIMEOUT_MS / 1000,
62
+ };
63
+ }
64
+
65
+ // Bun SQL and pg report a database that cannot be reached, or a connection that
66
+ // could not be opened or taken from a full pool in time, with these shapes.
67
+ const UNAVAILABLE_SQL_CODES = new Set([
68
+ "ERR_POSTGRES_CONNECTION_TIMEOUT",
69
+ "ERR_POSTGRES_CONNECTION_REFUSED",
70
+ "ERR_POSTGRES_CONNECTION_FAILED",
71
+ "ERR_POSTGRES_CONNECTION_CLOSED",
72
+ ]);
73
+ const UNAVAILABLE_PG_MESSAGES = new Set([
74
+ "timeout exceeded when trying to connect",
75
+ "Connection terminated due to connection timeout",
76
+ ]);
77
+
78
+ /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
79
+ export function isDatabaseUnavailable(error: unknown): boolean {
80
+ let current: unknown = error;
81
+ for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
82
+ if (current instanceof SQL.PostgresError && UNAVAILABLE_SQL_CODES.has(current.code)) return true;
83
+ if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
84
+ current = current.cause;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function createSql(provider: DatabaseProviderName, env: AppEnv): SQL {
90
+ const url = databaseUrl(env);
91
+ return provider === "sqlite"
92
+ ? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
93
+ : new SQL(url, postgresPoolOptions(env));
94
+ }
95
+
50
96
  export class Database {
51
97
  readonly provider: DatabaseProviderName = selectedDatabaseProvider();
52
98
  readonly sql: SQL;
53
99
  private ready?: Promise<void>;
54
100
 
55
- constructor(env: AppEnv) {
56
- const url = databaseUrl(env);
57
- this.sql = this.provider === "sqlite"
58
- ? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
59
- : new SQL(url, { max: 20 });
101
+ constructor(env: AppEnv, sql?: SQL) {
102
+ this.sql = sql ?? createSql(this.provider, env);
60
103
  }
61
104
 
62
105
  connect(): Promise<void> {
63
- this.ready ??= (async () => {
64
- if (this.provider === "sqlite") {
65
- await this.sql`PRAGMA journal_mode = WAL`;
66
- await this.sql`PRAGMA foreign_keys = ON`;
67
- await this.sql`PRAGMA busy_timeout = 5000`;
68
- }
69
- await this.sql`SELECT 1`;
70
- })();
106
+ if (!this.ready) {
107
+ const attempt = this.initialize();
108
+ this.ready = attempt;
109
+ // Forget a failed start so the next call retries instead of replaying the failure.
110
+ void attempt.catch(() => {
111
+ if (this.ready === attempt) this.ready = undefined;
112
+ });
113
+ }
71
114
  return this.ready;
72
115
  }
73
116
 
74
117
  async ping(): Promise<void> {
75
118
  await this.connect();
119
+ await this.sql`SELECT 1`;
76
120
  }
77
121
 
78
122
  async close(): Promise<void> {
79
123
  await this.sql.close();
80
124
  }
125
+
126
+ private async initialize(): Promise<void> {
127
+ if (this.provider === "sqlite") {
128
+ await this.sql`PRAGMA journal_mode = WAL`;
129
+ await this.sql`PRAGMA foreign_keys = ON`;
130
+ await this.sql`PRAGMA busy_timeout = 5000`;
131
+ }
132
+ await this.sql`SELECT 1`;
133
+ }
81
134
  }