@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
@@ -0,0 +1,86 @@
1
+ import { SQL } from "bun";
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
4
+ import { Elysia } from "elysia";
5
+ import { AppException } from "../common/app-exception";
6
+ import { validateEnv } from "../config/env.validation";
7
+ import { PROVIDERS } from "../config/providers";
8
+ import {
9
+ appContext,
10
+ createCoreServices,
11
+ DATABASE,
12
+ READINESS,
13
+ type ServiceRegistry,
14
+ } from "../core/services";
15
+ import { Database } from "../database/database";
16
+ import type { ReadinessCheck } from "./readiness.service";
17
+ import { healthPlugin } from "./health.plugin";
18
+
19
+ const opened: ServiceRegistry[] = [];
20
+
21
+ function databaseProvider(): DatabaseProviderName {
22
+ return PROVIDERS.database;
23
+ }
24
+
25
+ afterEach(async () => {
26
+ for (const services of opened.splice(0)) await services.close();
27
+ });
28
+
29
+ async function readiness(
30
+ options: { databaseUp: boolean; checks?: Record<string, ReadinessCheck> },
31
+ ): Promise<{ status: number; body: unknown }> {
32
+ const env = validateEnv({
33
+ NODE_ENV: "test",
34
+ DATABASE_URL: databaseProvider() === "sqlite"
35
+ ? "sqlite://:memory:"
36
+ : "postgres://podokit:podokit@localhost:5432/podokit",
37
+ READINESS_CHECK_TIMEOUT_MS: "20",
38
+ READINESS_CACHE_MS: "0",
39
+ });
40
+ const services = createCoreServices(env);
41
+ opened.push(services);
42
+ const sql = new SQL(":memory:", { adapter: "sqlite" });
43
+ if (!options.databaseUp) await sql.close();
44
+ const database = new Database(env, sql);
45
+ services.override(DATABASE, database, () => database.close());
46
+ for (const [name, check] of Object.entries(options.checks ?? {})) {
47
+ services.resolve(READINESS).register(name, check);
48
+ }
49
+ const app = new Elysia()
50
+ .onError(({ error, set }) => {
51
+ set.status = error instanceof AppException ? error.statusCode : 500;
52
+ return { code: error instanceof AppException ? error.code : "INTERNAL_ERROR" };
53
+ })
54
+ .use(healthPlugin(appContext(env, services)));
55
+ const response = await app.handle(new Request("http://localhost/health/ready"));
56
+ return { status: response.status, body: await response.json() };
57
+ }
58
+
59
+ describe("GET /health/ready", () => {
60
+ it("reports the database as db and module checks separately", async () => {
61
+ const result = await readiness({
62
+ databaseUp: true,
63
+ checks: { cache: () => Promise.resolve() },
64
+ });
65
+
66
+ expect(result).toEqual({
67
+ status: 200,
68
+ body: { status: "ready", db: "up", checks: { cache: "up" } },
69
+ });
70
+ });
71
+
72
+ it("fails when the database is down", async () => {
73
+ const result = await readiness({ databaseUp: false });
74
+
75
+ expect(result).toEqual({ status: 503, body: { code: "READINESS_FAILED" } });
76
+ });
77
+
78
+ it("fails when a module check exceeds its timeout", async () => {
79
+ const result = await readiness({
80
+ databaseUp: true,
81
+ checks: { storage: () => new Promise<void>(() => {}) },
82
+ });
83
+
84
+ expect(result).toEqual({ status: 503, body: { code: "READINESS_FAILED" } });
85
+ });
86
+ });
@@ -1,8 +1,9 @@
1
1
  import { Elysia } from "elysia";
2
2
  import { AppException } from "../common/app-exception";
3
3
  import type { AppPlugin } from "../core/services";
4
+ import { DATABASE_READINESS_CHECK } from "./readiness.service";
4
5
 
5
- export const healthPlugin: AppPlugin = ({ database, readiness }) =>
6
+ export const healthPlugin: AppPlugin = ({ readiness }) =>
6
7
  new Elysia({ name: "podokit.health" })
7
8
  .get(
8
9
  "/health",
@@ -12,14 +13,8 @@ export const healthPlugin: AppPlugin = ({ database, readiness }) =>
12
13
  .get(
13
14
  "/health/ready",
14
15
  async () => {
15
- let db: "up" | "down" = "up";
16
- try {
17
- await database.ping();
18
- } catch {
19
- db = "down";
20
- }
21
- const checks = await readiness.run();
22
- if (db === "down" || Object.values(checks).includes("down")) {
16
+ const { [DATABASE_READINESS_CHECK]: db, ...checks } = await readiness.run();
17
+ if (db !== "up" || Object.values(checks).includes("down")) {
23
18
  throw new AppException("READINESS_FAILED", "One or more dependencies are unavailable", 503);
24
19
  }
25
20
  return { status: "ready" as const, db: "up" as const, checks };
@@ -1,6 +1,23 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it, setSystemTime } from "bun:test";
2
2
  import { ReadinessService } from "./readiness.service";
3
3
 
4
+ interface Deferred {
5
+ promise: Promise<void>;
6
+ resolve: () => void;
7
+ }
8
+
9
+ function deferred(): Deferred {
10
+ let resolve: () => void = () => {};
11
+ const promise = new Promise<void>((done) => {
12
+ resolve = done;
13
+ });
14
+ return { promise, resolve };
15
+ }
16
+
17
+ afterEach(() => {
18
+ setSystemTime();
19
+ });
20
+
4
21
  describe("ReadinessService", () => {
5
22
  it("runs registered checks and removes them with the returned callback", async () => {
6
23
  const service = new ReadinessService();
@@ -19,4 +36,127 @@ describe("ReadinessService", () => {
19
36
  'Readiness check "cache" is already registered',
20
37
  );
21
38
  });
39
+
40
+ it("reports a check that exceeds the timeout as down with the reason", async () => {
41
+ const failures: Array<[string, string]> = [];
42
+ const service = new ReadinessService({
43
+ timeoutMs: 20,
44
+ cacheMs: 0,
45
+ onCheckFailure: (name, reason) => failures.push([name, reason]),
46
+ });
47
+ service.register("storage", () => new Promise<void>(() => {}));
48
+ service.register("cache", () => Promise.resolve());
49
+
50
+ const started = performance.now();
51
+ await expect(service.run()).resolves.toEqual({ storage: "down", cache: "up" });
52
+ expect(performance.now() - started).toBeLessThan(1_000);
53
+ expect(failures).toEqual([["storage", 'Readiness check "storage" timed out after 20 ms']]);
54
+ });
55
+
56
+ it("reports the error message of a failed check", async () => {
57
+ const failures: Array<[string, string]> = [];
58
+ const service = new ReadinessService({
59
+ cacheMs: 0,
60
+ onCheckFailure: (name, reason) => failures.push([name, reason]),
61
+ });
62
+ service.register("events", () => Promise.reject(new Error("connection refused")));
63
+ service.register("cache", () => {
64
+ throw new Error("not connected");
65
+ });
66
+
67
+ await expect(service.run()).resolves.toEqual({ events: "down", cache: "down" });
68
+ expect(Object.fromEntries(failures)).toEqual({
69
+ events: "connection refused",
70
+ cache: "not connected",
71
+ });
72
+ });
73
+
74
+ it("reuses a finished evaluation until the cache window ends", async () => {
75
+ setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
76
+ let calls = 0;
77
+ const service = new ReadinessService({ cacheMs: 2_000 });
78
+ service.register("cache", async () => {
79
+ calls += 1;
80
+ });
81
+
82
+ await service.run();
83
+ setSystemTime(new Date("2026-01-01T00:00:01.999Z"));
84
+ await service.run();
85
+ expect(calls).toBe(1);
86
+
87
+ setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
88
+ await service.run();
89
+ expect(calls).toBe(2);
90
+ });
91
+
92
+ it("caches a failed evaluation for the same window", async () => {
93
+ setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
94
+ let available = false;
95
+ const service = new ReadinessService({ cacheMs: 2_000 });
96
+ service.register("cache", async () => {
97
+ if (!available) throw new Error("unavailable");
98
+ });
99
+
100
+ await expect(service.run()).resolves.toEqual({ cache: "down" });
101
+ available = true;
102
+ await expect(service.run()).resolves.toEqual({ cache: "down" });
103
+ setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
104
+ await expect(service.run()).resolves.toEqual({ cache: "up" });
105
+ });
106
+
107
+ it("evaluates on every run when the cache is disabled", async () => {
108
+ let calls = 0;
109
+ const service = new ReadinessService({ cacheMs: 0 });
110
+ service.register("cache", async () => {
111
+ calls += 1;
112
+ });
113
+
114
+ await service.run();
115
+ await service.run();
116
+ await service.run();
117
+ expect(calls).toBe(3);
118
+ });
119
+
120
+ it("shares one evaluation between concurrent runs", async () => {
121
+ let calls = 0;
122
+ const gate = deferred();
123
+ const service = new ReadinessService({ cacheMs: 0 });
124
+ service.register("cache", async () => {
125
+ calls += 1;
126
+ await gate.promise;
127
+ });
128
+
129
+ const runs = [service.run(), service.run(), service.run()];
130
+ gate.resolve();
131
+ const results = await Promise.all(runs);
132
+
133
+ expect(calls).toBe(1);
134
+ expect(results).toEqual([{ cache: "up" }, { cache: "up" }, { cache: "up" }]);
135
+ await service.run();
136
+ expect(calls).toBe(2);
137
+ });
138
+
139
+ it("does not share or cache an evaluation across a registration change", async () => {
140
+ const gate = deferred();
141
+ const service = new ReadinessService({ cacheMs: 60_000 });
142
+ service.register("cache", () => gate.promise);
143
+
144
+ const before = service.run();
145
+ service.register("events", () => Promise.resolve());
146
+ const after = service.run();
147
+ gate.resolve();
148
+
149
+ await expect(before).resolves.toEqual({ cache: "up" });
150
+ await expect(after).resolves.toEqual({ cache: "up", events: "up" });
151
+ await expect(service.run()).resolves.toEqual({ cache: "up", events: "up" });
152
+ });
153
+
154
+ it("returns a copy that callers cannot use to change the cached result", async () => {
155
+ const service = new ReadinessService({ cacheMs: 60_000 });
156
+ service.register("cache", () => Promise.resolve());
157
+
158
+ const first = await service.run();
159
+ first.cache = "down";
160
+ await expect(service.run()).resolves.toEqual({ cache: "up" });
161
+ });
22
162
  });
@@ -1,30 +1,104 @@
1
1
  export type ReadinessStatus = "up" | "down";
2
2
  export type ReadinessCheck = () => Promise<void>;
3
+ export type ReadinessResults = Record<string, ReadinessStatus>;
4
+
5
+ export const DEFAULT_READINESS_CHECK_TIMEOUT_MS = 800;
6
+ export const DEFAULT_READINESS_CACHE_MS = 2_000;
7
+
8
+ /** The core database check. The health endpoint reports it as `db`, apart from module checks. */
9
+ export const DATABASE_READINESS_CHECK = "database";
10
+
11
+ export interface ReadinessOptions {
12
+ /** Longest one check may run before it is reported down. */
13
+ timeoutMs?: number;
14
+ /** How long a finished evaluation is reused. `0` evaluates on every run. */
15
+ cacheMs?: number;
16
+ /** Receives the reason for each check that is down, once per evaluation. */
17
+ onCheckFailure?: (name: string, reason: string) => void;
18
+ }
19
+
20
+ class ReadinessTimeoutError extends Error {
21
+ constructor(name: string, timeoutMs: number) {
22
+ super(`Readiness check "${name}" timed out after ${timeoutMs} ms`);
23
+ this.name = "ReadinessTimeoutError";
24
+ }
25
+ }
3
26
 
4
27
  export class ReadinessService {
5
28
  private readonly checks = new Map<string, ReadinessCheck>();
29
+ private readonly timeoutMs: number;
30
+ private readonly cacheMs: number;
31
+ private readonly onCheckFailure: ((name: string, reason: string) => void) | undefined;
32
+ // Bumped whenever the registered checks change, so an evaluation that started
33
+ // earlier is neither shared nor cached afterwards.
34
+ private generation = 0;
35
+ private inFlight?: Promise<ReadinessResults>;
36
+ private cached?: { results: ReadinessResults; expiresAt: number };
37
+
38
+ constructor(options: ReadinessOptions = {}) {
39
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_READINESS_CHECK_TIMEOUT_MS;
40
+ this.cacheMs = options.cacheMs ?? DEFAULT_READINESS_CACHE_MS;
41
+ this.onCheckFailure = options.onCheckFailure;
42
+ }
6
43
 
7
44
  register(name: string, check: ReadinessCheck): () => void {
8
45
  if (this.checks.has(name)) {
9
46
  throw new Error(`Readiness check "${name}" is already registered`);
10
47
  }
11
48
  this.checks.set(name, check);
49
+ this.invalidate();
12
50
  return () => {
13
51
  this.checks.delete(name);
52
+ this.invalidate();
14
53
  };
15
54
  }
16
55
 
17
- async run(): Promise<Record<string, ReadinessStatus>> {
18
- const results = await Promise.all(
19
- [...this.checks].map(async ([name, check]): Promise<[string, ReadinessStatus]> => {
20
- try {
21
- await check();
22
- return [name, "up"];
23
- } catch {
24
- return [name, "down"];
25
- }
26
- }),
27
- );
28
- return Object.fromEntries(results);
56
+ /**
57
+ * Runs every check in parallel, each bounded by the timeout. Concurrent callers
58
+ * share one evaluation, and a finished one is reused for the cache window.
59
+ */
60
+ async run(): Promise<ReadinessResults> {
61
+ if (this.cached && Date.now() < this.cached.expiresAt) return { ...this.cached.results };
62
+ this.inFlight ??= this.evaluate(this.generation);
63
+ return { ...(await this.inFlight) };
64
+ }
65
+
66
+ private invalidate(): void {
67
+ this.generation += 1;
68
+ this.inFlight = undefined;
69
+ this.cached = undefined;
70
+ }
71
+
72
+ private async evaluate(generation: number): Promise<ReadinessResults> {
73
+ try {
74
+ const entries = await Promise.all(
75
+ [...this.checks].map(
76
+ async ([name, check]): Promise<[string, ReadinessStatus]> => [name, await this.probe(name, check)],
77
+ ),
78
+ );
79
+ const results: ReadinessResults = Object.fromEntries(entries);
80
+ if (generation === this.generation && this.cacheMs > 0) {
81
+ this.cached = { results, expiresAt: Date.now() + this.cacheMs };
82
+ }
83
+ return results;
84
+ } finally {
85
+ if (generation === this.generation) this.inFlight = undefined;
86
+ }
87
+ }
88
+
89
+ private async probe(name: string, check: ReadinessCheck): Promise<ReadinessStatus> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const timeout = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => reject(new ReadinessTimeoutError(name, this.timeoutMs)), this.timeoutMs);
93
+ });
94
+ try {
95
+ await Promise.race([check(), timeout]);
96
+ return "up";
97
+ } catch (error) {
98
+ this.onCheckFailure?.(name, error instanceof Error ? error.message : String(error));
99
+ return "down";
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
29
103
  }
30
104
  }
@@ -1,5 +1,8 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { gzipSync } from "node:zlib";
1
4
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { proxyRequest, resolveClientIp } from "./backend-proxy";
5
+ import { proxyRequest, rawSubpath, resolveClientIp } from "./backend-proxy";
3
6
 
4
7
  describe("backend proxy client address handling", () => {
5
8
  afterEach(() => {
@@ -77,6 +80,23 @@ describe("backend proxy client address handling", () => {
77
80
  expect(upstreamHeaders?.get("user-agent")).toBe(agent);
78
81
  });
79
82
 
83
+ it("aborts the upstream request when the client disconnects", async () => {
84
+ let upstreamSignal: AbortSignal | undefined;
85
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
86
+ upstreamSignal = init?.signal ?? undefined;
87
+ return new Response(null, { status: 204 });
88
+ });
89
+
90
+ const client = new AbortController();
91
+ const request = new Request("http://app.localhost/api/events", { signal: client.signal });
92
+ await proxyRequest(request, "http://api:5002/events");
93
+
94
+ expect(upstreamSignal).toBeDefined();
95
+ expect(upstreamSignal?.aborted).toBe(false);
96
+ client.abort();
97
+ expect(upstreamSignal?.aborted).toBe(true);
98
+ });
99
+
80
100
  it("relays rate-limit response headers to browser clients", async () => {
81
101
  vi.stubGlobal("fetch", async () => new Response(
82
102
  JSON.stringify({ success: false }),
@@ -101,4 +121,171 @@ describe("backend proxy client address handling", () => {
101
121
  expect(response.headers.get("ratelimit-limit")).toBe("300");
102
122
  expect(response.headers.get("ratelimit-remaining")).toBe("0");
103
123
  });
124
+
125
+ it("relays the next-page cursor of a cursor-paged list", async () => {
126
+ vi.stubGlobal("fetch", async () => new Response("[]", {
127
+ headers: { "content-type": "application/json", "x-next-cursor": "WyIyMDI2Il0" },
128
+ }));
129
+
130
+ const response = await proxyRequest(
131
+ new Request("http://app.localhost/api/audit-logs?limit=15"),
132
+ "http://api:5002/audit-logs?limit=15",
133
+ );
134
+
135
+ expect(response.headers.get("x-next-cursor")).toBe("WyIyMDI2Il0");
136
+ });
137
+
138
+ it("relays cache validator and download headers", async () => {
139
+ vi.stubGlobal("fetch", async () => new Response("report", {
140
+ headers: {
141
+ "content-type": "text/csv",
142
+ etag: '"report-v3"',
143
+ "last-modified": "Wed, 14 Oct 2026 08:00:00 GMT",
144
+ "content-disposition": 'attachment; filename="report.csv"',
145
+ vary: "accept-encoding, cookie",
146
+ "x-content-type-options": "nosniff",
147
+ },
148
+ }));
149
+
150
+ const response = await proxyRequest(
151
+ new Request("http://app.localhost/api/reports/3"),
152
+ "http://api:5002/reports/3",
153
+ );
154
+
155
+ expect(response.headers.get("etag")).toBe('"report-v3"');
156
+ expect(response.headers.get("last-modified")).toBe("Wed, 14 Oct 2026 08:00:00 GMT");
157
+ expect(response.headers.get("content-disposition")).toBe('attachment; filename="report.csv"');
158
+ expect(response.headers.get("vary")).toBe("accept-encoding, cookie");
159
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
160
+ });
161
+
162
+ it("relays content-length for an uncompressed upstream body", async () => {
163
+ vi.stubGlobal("fetch", async () => new Response("0123456789", {
164
+ headers: { "content-length": "10" },
165
+ }));
166
+
167
+ const response = await proxyRequest(
168
+ new Request("http://app.localhost/api/files/1"),
169
+ "http://api:5002/files/1",
170
+ );
171
+
172
+ expect(response.headers.get("content-length")).toBe("10");
173
+ expect(await response.text()).toBe("0123456789");
174
+ });
175
+
176
+ it("drops content-length when the upstream body was compressed", async () => {
177
+ vi.stubGlobal("fetch", async () => new Response("decoded body", {
178
+ headers: { "content-encoding": "gzip", "content-length": "4" },
179
+ }));
180
+
181
+ const response = await proxyRequest(
182
+ new Request("http://app.localhost/api/files/1"),
183
+ "http://api:5002/files/1",
184
+ );
185
+
186
+ expect(response.headers.get("content-length")).toBeNull();
187
+ expect(response.headers.get("content-encoding")).toBeNull();
188
+ expect(await response.text()).toBe("decoded body");
189
+ });
190
+
191
+ it("delivers a gzip-encoded upstream body in full", async () => {
192
+ const payload = `${"podokit ".repeat(4096)}end`;
193
+ const compressed = gzipSync(payload);
194
+ const server = createServer((_request, response) => {
195
+ response.writeHead(200, {
196
+ "content-type": "text/plain",
197
+ "content-encoding": "gzip",
198
+ "content-length": String(compressed.byteLength),
199
+ });
200
+ response.end(compressed);
201
+ });
202
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
203
+ try {
204
+ const { port } = server.address() as AddressInfo;
205
+ const response = await proxyRequest(
206
+ new Request("http://app.localhost/api/export"),
207
+ `http://127.0.0.1:${String(port)}/export`,
208
+ );
209
+
210
+ expect(response.headers.get("content-length")).toBeNull();
211
+ expect(await response.text()).toBe(payload);
212
+ } finally {
213
+ await new Promise<void>((resolve) => server.close(() => resolve()));
214
+ }
215
+ });
216
+
217
+ it("forwards conditional and range request headers", async () => {
218
+ let upstreamHeaders: Headers | undefined;
219
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
220
+ upstreamHeaders = new Headers(init?.headers);
221
+ return new Response(null, { status: 204 });
222
+ });
223
+
224
+ const request = new Request("http://app.localhost/api/files/1", {
225
+ headers: {
226
+ "if-none-match": '"file-v1"',
227
+ "if-modified-since": "Wed, 14 Oct 2026 08:00:00 GMT",
228
+ range: "bytes=0-99",
229
+ "if-range": '"file-v1"',
230
+ },
231
+ });
232
+ await proxyRequest(request, "http://api:5002/files/1");
233
+
234
+ expect(upstreamHeaders?.get("if-none-match")).toBe('"file-v1"');
235
+ expect(upstreamHeaders?.get("if-modified-since")).toBe("Wed, 14 Oct 2026 08:00:00 GMT");
236
+ expect(upstreamHeaders?.get("range")).toBe("bytes=0-99");
237
+ expect(upstreamHeaders?.get("if-range")).toBe('"file-v1"');
238
+ });
239
+
240
+ it("passes a 304 Not Modified response through without a body", async () => {
241
+ vi.stubGlobal("fetch", async () => new Response(null, {
242
+ status: 304,
243
+ headers: { etag: '"file-v1"', "cache-control": "private, no-cache" },
244
+ }));
245
+
246
+ const response = await proxyRequest(
247
+ new Request("http://app.localhost/api/files/1", {
248
+ headers: { "if-none-match": '"file-v1"' },
249
+ }),
250
+ "http://api:5002/files/1",
251
+ );
252
+
253
+ expect(response.status).toBe(304);
254
+ expect(response.headers.get("etag")).toBe('"file-v1"');
255
+ expect(await response.text()).toBe("");
256
+ });
257
+
258
+ it("passes a 206 Partial Content response through with its range", async () => {
259
+ vi.stubGlobal("fetch", async () => new Response("0123", {
260
+ status: 206,
261
+ headers: {
262
+ "content-range": "bytes 0-3/10",
263
+ "accept-ranges": "bytes",
264
+ "content-length": "4",
265
+ },
266
+ }));
267
+
268
+ const response = await proxyRequest(
269
+ new Request("http://app.localhost/api/files/1", { headers: { range: "bytes=0-3" } }),
270
+ "http://api:5002/files/1",
271
+ );
272
+
273
+ expect(response.status).toBe(206);
274
+ expect(response.headers.get("content-range")).toBe("bytes 0-3/10");
275
+ expect(response.headers.get("accept-ranges")).toBe("bytes");
276
+ expect(response.headers.get("content-length")).toBe("4");
277
+ expect(await response.text()).toBe("0123");
278
+ });
279
+ });
280
+
281
+ describe("backend proxy raw path", () => {
282
+ it("keeps encoded separators inside a path segment", () => {
283
+ const url = new URL("http://localhost/api/files/uploads%2Fu1%2Fa%3Fb.png/url?x=1");
284
+ expect(rawSubpath(url, "/api/")).toBe("files/uploads%2Fu1%2Fa%3Fb.png/url");
285
+ });
286
+
287
+ it("strips only the given prefix", () => {
288
+ expect(rawSubpath(new URL("http://localhost/api/auth/get-session"), "/api/auth/")).toBe("get-session");
289
+ expect(rawSubpath(new URL("http://localhost/other"), "/api/")).toBe("/other");
290
+ });
104
291
  });
@@ -15,16 +15,36 @@ const FORWARDED_HEADERS = [
15
15
  "origin",
16
16
  "referer",
17
17
  "user-agent",
18
+ "if-none-match",
19
+ "if-modified-since",
20
+ "range",
21
+ "if-range",
18
22
  ];
19
23
  const RELAYED_RESPONSE_HEADERS = [
20
24
  "content-type",
25
+ "content-disposition",
26
+ "content-range",
27
+ "accept-ranges",
21
28
  "location",
22
29
  "cache-control",
30
+ "etag",
31
+ "last-modified",
32
+ "vary",
33
+ "x-content-type-options",
34
+ "content-security-policy",
23
35
  "retry-after",
24
36
  "ratelimit-limit",
25
37
  "ratelimit-remaining",
38
+ // Cursor-paged lists (such as the audit log) return the next page's cursor here.
39
+ "x-next-cursor",
26
40
  ];
27
41
 
42
+ // SvelteKit decodes rest params, so an encoded "%2F" or "%3F" inside a path
43
+ // segment would reach the API as "/" or "?". Forward the raw pathname instead.
44
+ export function rawSubpath(url: URL, prefix: string): string {
45
+ return url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
46
+ }
47
+
28
48
  export function backendBaseUrl(): string {
29
49
  return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
30
50
  }
@@ -71,6 +91,10 @@ export async function proxyRequest(
71
91
  // them as text corrupts bytes that are not valid UTF-8 before forwarding.
72
92
  body: hasBody ? await request.arrayBuffer() : undefined,
73
93
  redirect: "manual",
94
+ // Abort the upstream request when the browser disconnects. Without this, every
95
+ // closed EventSource or aborted request keeps its API socket open until Bun's
96
+ // fetch pool is exhausted and the web process stops serving requests.
97
+ signal: request.signal,
74
98
  });
75
99
 
76
100
  const responseHeaders = new Headers();
@@ -78,6 +102,12 @@ export async function proxyRequest(
78
102
  const value = upstream.headers.get(name);
79
103
  if (value) responseHeaders.set(name, value);
80
104
  }
105
+ // Bun's fetch decodes a compressed body but keeps the upstream content-encoding
106
+ // and compressed content-length, which would truncate the decoded body.
107
+ const contentLength = upstream.headers.get("content-length");
108
+ if (contentLength && !upstream.headers.has("content-encoding")) {
109
+ responseHeaders.set("content-length", contentLength);
110
+ }
81
111
  // Relay Set-Cookie so auth session cookies reach the browser.
82
112
  for (const cookie of upstream.headers.getSetCookie()) {
83
113
  responseHeaders.append("set-cookie", cookie);
@@ -1,11 +1,11 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest, resolveClientIp } from "#lib/server/backend-proxy.js";
2
+ import { backendBaseUrl, proxyRequest, rawSubpath, resolveClientIp } from "#lib/server/backend-proxy.js";
3
3
 
4
4
  // Proxy the app's REST API, stripping the /api prefix (backend routes are at root).
5
- const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
5
+ const handler: RequestHandler = ({ request, url, getClientAddress }) =>
6
6
  proxyRequest(
7
7
  request,
8
- `${backendBaseUrl()}/${params.path}${url.search}`,
8
+ `${backendBaseUrl()}/${rawSubpath(url, "/api/")}${url.search}`,
9
9
  resolveClientIp(getClientAddress),
10
10
  );
11
11
 
@@ -1,11 +1,11 @@
1
1
  import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, proxyRequest, resolveClientIp } from "#lib/server/backend-proxy.js";
2
+ import { backendBaseUrl, proxyRequest, rawSubpath, resolveClientIp } from "#lib/server/backend-proxy.js";
3
3
 
4
4
  // Proxy the better-auth handler, keeping the /api/auth prefix.
5
- const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
5
+ const handler: RequestHandler = ({ request, url, getClientAddress }) =>
6
6
  proxyRequest(
7
7
  request,
8
- `${backendBaseUrl()}/api/auth/${params.all}${url.search}`,
8
+ `${backendBaseUrl()}/api/auth/${rawSubpath(url, "/api/auth/")}${url.search}`,
9
9
  resolveClientIp(getClientAddress),
10
10
  );
11
11