@podosoft/podokit 1.4.1 → 1.5.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 (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. package/package.json +2 -2
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { validateEnv } from "../config/env.validation";
3
+ import {
4
+ CLIENT_ADDRESS_HEADER,
5
+ clientAddressOf,
6
+ resolveClientAddress,
7
+ stampClientAddress,
8
+ } from "./client-address";
9
+
10
+ const forwarded = (value: string): Headers => new Headers({ "x-forwarded-for": value });
11
+ const config = (trustedProxyHops: number) => ({ trustedProxyHops, header: "x-forwarded-for" });
12
+
13
+ describe("resolveClientAddress", () => {
14
+ it("uses the socket peer and ignores a caller's header without trusted proxies", () => {
15
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "10.0.0.9", config(0))).toBe("10.0.0.9");
16
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "::ffff:10.0.0.9", config(0))).toBe("10.0.0.9");
17
+ expect(resolveClientAddress(new Headers(), undefined, config(0))).toBeUndefined();
18
+ });
19
+
20
+ it("takes the entry the trusted proxies appended, never one further left", () => {
21
+ // A caller sent "198.51.100.66"; the one trusted proxy appended the real client.
22
+ const chain = forwarded("198.51.100.66, 203.0.113.7");
23
+ expect(resolveClientAddress(chain, "10.0.0.9", config(1))).toBe("203.0.113.7");
24
+ expect(resolveClientAddress(chain, "10.0.0.9", config(2))).toBe("198.51.100.66");
25
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(1))).toBe("203.0.113.7");
26
+ expect(
27
+ resolveClientAddress(new Headers({ "x-real-ip": "203.0.113.8" }), "10.0.0.9", {
28
+ trustedProxyHops: 1,
29
+ header: "x-real-ip",
30
+ }),
31
+ ).toBe("203.0.113.8");
32
+ });
33
+
34
+ it("falls back to the peer for a short or malformed chain", () => {
35
+ expect(resolveClientAddress(new Headers(), "10.0.0.9", config(1))).toBe("10.0.0.9");
36
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(2))).toBe("10.0.0.9");
37
+ expect(resolveClientAddress(forwarded("attacker-controlled"), "10.0.0.9", config(1))).toBe("10.0.0.9");
38
+ expect(resolveClientAddress(forwarded("203.0.113.7, not-an-ip"), "10.0.0.9", config(1))).toBe("10.0.0.9");
39
+ });
40
+
41
+ it("reads the address the API set", () => {
42
+ expect(clientAddressOf(new Headers({ [CLIENT_ADDRESS_HEADER]: "203.0.113.7" }))).toBe("203.0.113.7");
43
+ expect(clientAddressOf(new Headers())).toBeUndefined();
44
+ expect(clientAddressOf(undefined)).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("client address settings", () => {
49
+ it("validates the trusted proxy settings and refuses the rate limiter's former names", () => {
50
+ expect(validateEnv({})).toMatchObject({ TRUSTED_PROXY_HOPS: 0, TRUSTED_PROXY_HEADER: "x-forwarded-for" });
51
+ expect(validateEnv({ TRUSTED_PROXY_HOPS: "1", TRUSTED_PROXY_HEADER: "X-Real-IP" }))
52
+ .toMatchObject({ TRUSTED_PROXY_HOPS: 1, TRUSTED_PROXY_HEADER: "x-real-ip" });
53
+ expect(() => validateEnv({ TRUSTED_PROXY_HOPS: "-1" })).toThrow("TRUSTED_PROXY_HOPS");
54
+ expect(() => validateEnv({ TRUSTED_PROXY_HEADER: "bad header" })).toThrow("TRUSTED_PROXY_HEADER");
55
+ expect(() => validateEnv({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "1" }))
56
+ .toThrow("RATE_LIMIT_TRUSTED_PROXY_HOPS was renamed to TRUSTED_PROXY_HOPS");
57
+ expect(() => validateEnv({ RATE_LIMIT_PROXY_HEADER: "x-forwarded-for" }))
58
+ .toThrow("RATE_LIMIT_PROXY_HEADER was renamed to TRUSTED_PROXY_HEADER");
59
+ });
60
+ });
61
+
62
+ describe("stampClientAddress", () => {
63
+ const request = (headers: Record<string, string>): Request => new Request("http://localhost/x", { headers });
64
+
65
+ it("replaces a caller's own address header and ignores its forwarded header without trusted proxies", () => {
66
+ const spoofed = request({ "x-forwarded-for": "198.51.100.66", [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
67
+ expect(stampClientAddress(spoofed, "::ffff:127.0.0.1", config(0))).toBe("127.0.0.1");
68
+ expect(clientAddressOf(spoofed.headers)).toBe("127.0.0.1");
69
+ });
70
+
71
+ it("records the rightmost forwarded entry behind one trusted proxy", () => {
72
+ const proxied = request({ "x-forwarded-for": "198.51.100.66, 203.0.113.7" });
73
+ expect(stampClientAddress(proxied, "10.0.0.9", config(1))).toBe("203.0.113.7");
74
+ expect(clientAddressOf(proxied.headers)).toBe("203.0.113.7");
75
+ });
76
+
77
+ it("removes a caller's value when no address can be resolved", () => {
78
+ const unknown = request({ [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
79
+ expect(stampClientAddress(unknown, undefined, config(0))).toBeUndefined();
80
+ expect(clientAddressOf(unknown.headers)).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,68 @@
1
+ import { isIP } from "node:net";
2
+
3
+ /**
4
+ * The request header that carries the client address the API resolved. The API
5
+ * sets it on every request before any handler runs, replacing whatever a caller
6
+ * sent, so Better Auth, audit entries, and application code read one address
7
+ * that a caller cannot choose.
8
+ */
9
+ export const CLIENT_ADDRESS_HEADER = "x-podokit-client-address";
10
+
11
+ export interface ClientAddressConfig {
12
+ /** Reverse proxies in front of the API whose forwarded header is trusted; 0 trusts none. */
13
+ trustedProxyHops: number;
14
+ /** The lowercase header those proxies append the client address to. */
15
+ header: string;
16
+ }
17
+
18
+ /** An IP address without its IPv4-mapped IPv6 prefix, or undefined when the value is not one. */
19
+ export function normalizeIp(value: string | undefined): string | undefined {
20
+ if (!value) return undefined;
21
+ const trimmed = value.trim();
22
+ const normalized = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed;
23
+ return isIP(normalized) === 0 ? undefined : normalized;
24
+ }
25
+
26
+ /**
27
+ * The caller's address. With no trusted proxy it is the socket peer. Otherwise
28
+ * each trusted proxy appended one entry to the header, so the client is the
29
+ * entry that many places left of the peer; entries further left were written
30
+ * by the caller and are ignored. A chain shorter than the trusted hops, or a
31
+ * malformed entry, falls back to the peer instead of trusting caller input.
32
+ */
33
+ export function resolveClientAddress(
34
+ headers: Headers,
35
+ remoteAddress: string | undefined,
36
+ config: ClientAddressConfig,
37
+ ): string | undefined {
38
+ const direct = normalizeIp(remoteAddress);
39
+ if (config.trustedProxyHops === 0) return direct;
40
+ const forwarded = headers.get(config.header)
41
+ ?.split(",")
42
+ .map((value) => value.trim())
43
+ .filter(Boolean) ?? [];
44
+ const chain = [...forwarded, direct ?? ""];
45
+ if (chain.length <= config.trustedProxyHops) return direct;
46
+ return normalizeIp(chain[chain.length - config.trustedProxyHops - 1]) ?? direct;
47
+ }
48
+
49
+ /**
50
+ * Resolves a request's client address and records it in CLIENT_ADDRESS_HEADER,
51
+ * replacing any value the caller sent. The API does this for every request
52
+ * before a guard, Better Auth, or a handler reads the request.
53
+ */
54
+ export function stampClientAddress(
55
+ request: Request,
56
+ remoteAddress: string | undefined,
57
+ config: ClientAddressConfig,
58
+ ): string | undefined {
59
+ const address = resolveClientAddress(request.headers, remoteAddress, config);
60
+ if (address) request.headers.set(CLIENT_ADDRESS_HEADER, address);
61
+ else request.headers.delete(CLIENT_ADDRESS_HEADER);
62
+ return address;
63
+ }
64
+
65
+ /** The address the API resolved for a request, as set in CLIENT_ADDRESS_HEADER. */
66
+ export function clientAddressOf(headers: Headers | undefined): string | undefined {
67
+ return headers?.get(CLIENT_ADDRESS_HEADER) ?? undefined;
68
+ }
@@ -280,7 +280,10 @@ export class AccessPolicy {
280
280
 
281
281
  export interface RequestGuardContext {
282
282
  request: Request;
283
+ /** The socket peer. */
283
284
  remoteAddress?: string;
285
+ /** The caller's address behind the trusted proxies; see core/client-address.ts. */
286
+ clientAddress?: string;
284
287
  setHeader: (name: string, value: string) => void;
285
288
  }
286
289
 
@@ -305,6 +308,31 @@ export interface RequestIdentity {
305
308
  activeOrganizationId(request: Request): Promise<string | undefined>;
306
309
  }
307
310
 
311
+ /** A session the API resolved while handling a request. */
312
+ export interface ResolvedSession {
313
+ /** The session token without its cookie signature. */
314
+ token: string;
315
+ userId: string;
316
+ expiresAt: Date;
317
+ }
318
+
319
+ /**
320
+ * Passes each session the API resolves for a request to the modules that listen,
321
+ * so they can use it without a session lookup of their own. The auth module
322
+ * reports; the rate limiter listens to count a signed-in caller per user.
323
+ */
324
+ export class ResolvedSessions {
325
+ private readonly listeners: Array<(session: ResolvedSession) => void> = [];
326
+
327
+ listen(listener: (session: ResolvedSession) => void): void {
328
+ this.listeners.push(listener);
329
+ }
330
+
331
+ report(session: ResolvedSession): void {
332
+ for (const listener of this.listeners) listener(session);
333
+ }
334
+ }
335
+
308
336
  export interface OpenApiContribution {
309
337
  document: Record<string, unknown>;
310
338
  pathPrefix?: string;
@@ -331,6 +359,7 @@ export const LOGGER = Symbol("logger") as ServiceKey<Logger>;
331
359
  export const ACCESS_POLICY = Symbol("access-policy") as ServiceKey<AccessPolicy>;
332
360
  export const REQUEST_GUARDS = Symbol("request-guards") as ServiceKey<RequestGuardRegistry>;
333
361
  export const REQUEST_IDENTITY = Symbol("request-identity") as ServiceKey<RequestIdentity>;
362
+ export const RESOLVED_SESSIONS = Symbol("resolved-sessions") as ServiceKey<ResolvedSessions>;
334
363
  export const OPENAPI = Symbol("openapi") as ServiceKey<OpenApiRegistry>;
335
364
 
336
365
  export interface AppContext {
@@ -390,6 +419,7 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
390
419
  roles: async () => [],
391
420
  activeOrganizationId: async () => undefined,
392
421
  });
422
+ services.register(RESOLVED_SESSIONS, new ResolvedSessions());
393
423
  services.register(OPENAPI, openapiRegistry);
394
424
  return services;
395
425
  }
@@ -1,8 +1,10 @@
1
1
  import { SQL } from "bun";
2
2
  import { describe, expect, it } from "bun:test";
3
3
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { connect, createServer, type AddressInfo } from "node:net";
4
5
  import { tmpdir } from "node:os";
5
6
  import { dirname, join } from "node:path";
7
+ import { QueryFailedError } from "typeorm";
6
8
  import { validateEnv } from "../config/env.validation";
7
9
  import { PROVIDERS } from "../config/providers";
8
10
  import {
@@ -199,6 +201,45 @@ describe("isDatabaseUnavailable", () => {
199
201
  expect(isDatabaseUnavailable(new Error("Failed to save", { cause: socket("ECONNREFUSED") }))).toBe(true);
200
202
  });
201
203
 
204
+ it("recognizes host lookup failures from Bun and Node", () => {
205
+ // Bun reports an unknown host as ENOTFOUND and a lookup timeout as ETIMEOUT;
206
+ // Node reports a lookup it could not complete as EAI_AGAIN.
207
+ for (const code of ["ENOTFOUND", "ETIMEOUT", "EAI_AGAIN"]) {
208
+ expect(isDatabaseUnavailable(Object.assign(new Error(`getaddrinfo ${code} db`), { code }))).toBe(true);
209
+ }
210
+ });
211
+
212
+ it("recognizes a pg client that was ended or broke while a query waited", () => {
213
+ for (const message of [
214
+ "Connection terminated",
215
+ "Client was closed and is not queryable",
216
+ "Client has encountered a connection error and is not queryable",
217
+ ]) {
218
+ expect(isDatabaseUnavailable(new Error(message))).toBe(true);
219
+ }
220
+ });
221
+
222
+ it("follows the per-address errors of a connection that tried several addresses", () => {
223
+ const refused = (address: string): Error =>
224
+ Object.assign(new Error(`connect ECONNREFUSED ${address}:5432`), { code: "ECONNREFUSED" });
225
+ expect(isDatabaseUnavailable(new AggregateError([refused("::1"), refused("127.0.0.1")]))).toBe(true);
226
+ expect(isDatabaseUnavailable(new AggregateError([new Error("first"), new Error("second")]))).toBe(false);
227
+ });
228
+
229
+ it("follows the driver error that TypeORM wraps", () => {
230
+ const failed = (driverError: Error): QueryFailedError => new QueryFailedError("SELECT 1", [], driverError);
231
+ expect(isDatabaseUnavailable(failed(new Error("Connection terminated")))).toBe(true);
232
+ const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), { code: "ECONNREFUSED" });
233
+ expect(isDatabaseUnavailable(failed(new AggregateError([refused])))).toBe(true);
234
+ expect(isDatabaseUnavailable(failed(Object.assign(new Error("duplicate key"), { code: "23505" })))).toBe(false);
235
+ });
236
+
237
+ it("stops at an error that wraps itself", () => {
238
+ const loop = new Error("outer");
239
+ loop.cause = new Error("inner", { cause: loop });
240
+ expect(isDatabaseUnavailable(loop)).toBe(false);
241
+ });
242
+
202
243
  it("leaves query and application errors alone", () => {
203
244
  expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
204
245
  .toBe(false);
@@ -212,6 +253,31 @@ describe("isDatabaseUnavailable", () => {
212
253
  expect(isDatabaseUnavailable(undefined)).toBe(false);
213
254
  });
214
255
 
256
+ it("recognizes a real lookup of a host that does not exist", async () => {
257
+ // .invalid never resolves (RFC 2606). A resolver that does not answer at all
258
+ // fails with ETIMEOUT after about 16 seconds, which is recognized too.
259
+ const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
260
+ expect(isDatabaseUnavailable(error)).toBe(true);
261
+ }, 30_000);
262
+
263
+ it("recognizes a real refused connection to localhost", async () => {
264
+ const server = createServer();
265
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
266
+ const { port } = server.address() as AddressInfo;
267
+ await new Promise<void>((resolve) => server.close(() => resolve()));
268
+ // localhost may resolve to ::1 and 127.0.0.1; a client that tries both fails
269
+ // with an AggregateError of the two refusals.
270
+ const error = await new Promise<unknown>((resolve) => {
271
+ const socket = connect({ host: "localhost", port });
272
+ socket.once("connect", () => {
273
+ socket.destroy();
274
+ resolve(undefined);
275
+ });
276
+ socket.once("error", resolve);
277
+ });
278
+ expect(isDatabaseUnavailable(error)).toBe(true);
279
+ });
280
+
215
281
  it("recognizes a real Bun SQL connection timeout", async () => {
216
282
  // Accepts the TCP connection and never answers, like an overloaded server.
217
283
  const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
@@ -76,9 +76,14 @@ const UNAVAILABLE_PG_MESSAGES = new Set([
76
76
  "timeout exceeded when trying to connect",
77
77
  "Connection terminated due to connection timeout",
78
78
  "Connection terminated unexpectedly",
79
+ // The client was ended, for example by a pool replacement, while a query waited.
80
+ "Connection terminated",
81
+ "Client was closed and is not queryable",
82
+ "Client has encountered a connection error and is not queryable",
79
83
  "Query read timeout",
80
84
  ]);
81
- // Socket errors from a pg client whose server is gone or unreachable.
85
+ // Socket and host lookup errors from a pg client whose server is gone or
86
+ // unreachable. Bun reports a lookup timeout as ETIMEOUT, Node as EAI_AGAIN.
82
87
  const UNAVAILABLE_SOCKET_CODES = new Set([
83
88
  "ECONNREFUSED",
84
89
  "ECONNRESET",
@@ -86,6 +91,9 @@ const UNAVAILABLE_SOCKET_CODES = new Set([
86
91
  "EHOSTUNREACH",
87
92
  "ENETUNREACH",
88
93
  "EPIPE",
94
+ "ENOTFOUND",
95
+ "ETIMEOUT",
96
+ "EAI_AGAIN",
89
97
  ]);
90
98
  // SQLSTATEs for a server shutting down or refusing connections (class 57P) and
91
99
  // for broken connections (class 08); pg reports them as `code`, Bun SQL as `errno`.
@@ -95,18 +103,33 @@ function unavailableSqlState(value: unknown): boolean {
95
103
  return typeof value === "string" && (UNAVAILABLE_SQLSTATES.has(value) || /^08[0-9A-Z]{3}$/.test(value));
96
104
  }
97
105
 
106
+ function signalsUnavailable(error: Error): boolean {
107
+ if (error instanceof SQL.PostgresError) {
108
+ if (UNAVAILABLE_SQL_CODES.has(error.code) || unavailableSqlState(error.errno)) return true;
109
+ } else {
110
+ const code = (error as { code?: unknown }).code;
111
+ if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
112
+ }
113
+ return UNAVAILABLE_PG_MESSAGES.has(error.message);
114
+ }
115
+
116
+ // Bounds the walk over wrapped errors, which may form a cycle.
117
+ const MAX_WRAPPED_ERRORS = 16;
118
+
98
119
  /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
99
120
  export function isDatabaseUnavailable(error: unknown): boolean {
100
- let current: unknown = error;
101
- for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
102
- if (current instanceof SQL.PostgresError) {
103
- if (UNAVAILABLE_SQL_CODES.has(current.code) || unavailableSqlState(current.errno)) return true;
104
- } else {
105
- const code = (current as { code?: unknown }).code;
106
- if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
107
- }
108
- if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
109
- current = current.cause;
121
+ const pending: unknown[] = [error];
122
+ const seen = new Set<Error>();
123
+ while (pending.length > 0 && seen.size < MAX_WRAPPED_ERRORS) {
124
+ const current = pending.shift();
125
+ if (!(current instanceof Error) || seen.has(current)) continue;
126
+ seen.add(current);
127
+ if (signalsUnavailable(current)) return true;
128
+ // TypeORM's QueryFailedError keeps the driver's error as `driverError`, and
129
+ // a connection that tried several addresses, such as IPv6 and IPv4 for
130
+ // localhost, fails with an AggregateError of the per-address errors.
131
+ pending.push(current.cause, (current as { driverError?: unknown }).driverError);
132
+ if (current instanceof AggregateError) pending.push(...current.errors);
110
133
  }
111
134
  return false;
112
135
  }
@@ -11,18 +11,29 @@ interface Launch {
11
11
  stderr: string;
12
12
  }
13
13
 
14
+ interface LaunchOptions {
15
+ /** Starts the launcher from the directory above the API, like a repository root. */
16
+ fromParent?: boolean;
17
+ files?: Record<string, string>;
18
+ }
19
+
14
20
  /** Runs the migrate:all launcher in an API layout whose entries print their own path. */
15
- async function launch(entries: string[], nodeEnv: string, body = ""): Promise<Launch> {
16
- const root = mkdtempSync(join(tmpdir(), "run-migrations-"));
21
+ async function launch(entries: string[], nodeEnv: string, body = "", options: LaunchOptions = {}): Promise<Launch> {
22
+ const parent = mkdtempSync(join(tmpdir(), "run-migrations-"));
23
+ const root = join(parent, "api");
17
24
  try {
18
- mkdirSync(join(root, "scripts"));
25
+ mkdirSync(join(root, "scripts"), { recursive: true });
19
26
  cpSync(LAUNCHER, join(root, "scripts/run-migrations.mjs"));
20
27
  for (const entry of entries) {
21
28
  mkdirSync(dirname(join(root, entry)), { recursive: true });
22
29
  writeFileSync(join(root, entry), `console.log(${JSON.stringify(entry)});\n${body}`);
23
30
  }
24
- const child = Bun.spawn([process.execPath, "scripts/run-migrations.mjs"], {
25
- cwd: root,
31
+ for (const [path, content] of Object.entries(options.files ?? {})) {
32
+ mkdirSync(dirname(join(root, path)), { recursive: true });
33
+ writeFileSync(join(root, path), content);
34
+ }
35
+ const child = Bun.spawn([process.execPath, options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs"], {
36
+ cwd: options.fromParent ? parent : root,
26
37
  env: { ...process.env, NODE_ENV: nodeEnv },
27
38
  stdout: "pipe",
28
39
  stderr: "pipe",
@@ -34,7 +45,7 @@ async function launch(entries: string[], nodeEnv: string, body = ""): Promise<La
34
45
  ]);
35
46
  return { code, stdout: stdout.trim(), stderr };
36
47
  } finally {
37
- rmSync(root, { recursive: true, force: true });
48
+ rmSync(parent, { recursive: true, force: true });
38
49
  }
39
50
  }
40
51
 
@@ -62,6 +73,43 @@ describe("migrate:all launcher", () => {
62
73
  expect(result.code).toBe(3);
63
74
  });
64
75
 
76
+ it("runs the migration in the API directory when started from elsewhere", async () => {
77
+ const apiRoot = 'realpathSync(join(import.meta.dir, "../.."))';
78
+ const result = await launch(
79
+ ["src/database/migrate.ts"],
80
+ "development",
81
+ `import { realpathSync } from "node:fs";\nimport { join } from "node:path";\n` +
82
+ `console.log(realpathSync(process.cwd()) === ${apiRoot});\n`,
83
+ { fromParent: true },
84
+ );
85
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\ntrue" });
86
+ });
87
+
88
+ it("applies the API's decorator settings when started from elsewhere", async () => {
89
+ // TypeORM entities need legacy decorators, which Bun takes from the
90
+ // tsconfig.json of the working directory rather than of the source file.
91
+ const result = await launch(
92
+ ["src/database/migrate.ts"],
93
+ "development",
94
+ 'await import("../entity.ts");\n',
95
+ {
96
+ fromParent: true,
97
+ files: {
98
+ "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }),
99
+ "src/entity.ts": [
100
+ "const Column = (): PropertyDecorator => (target) => console.log(typeof target);",
101
+ "export class Item {",
102
+ " @Column()",
103
+ " name!: string;",
104
+ "}",
105
+ "",
106
+ ].join("\n"),
107
+ },
108
+ },
109
+ );
110
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\nobject" });
111
+ });
112
+
65
113
  it("fails when no migration entry exists", async () => {
66
114
  const result = await launch([], "development");
67
115
  expect(result.code).toBe(1);
@@ -32,10 +32,14 @@ FROM oven/bun:1.4.0-alpine AS runtime
32
32
  WORKDIR /app
33
33
  ENV NODE_ENV=production
34
34
  ENV BODY_SIZE_LIMIT=3M
35
+ # Seconds a connection may stay silent, such as while the /api proxy waits for the
36
+ # API: more than 4 above BACKEND_PROXY_TIMEOUT_MS (30 s), since Bun closes idle
37
+ # connections on a 4-second tick.
38
+ ENV CONNECTION_IDLE_TIMEOUT=35
35
39
  COPY --from=build /app/apps/web/build ./apps/web/build
36
40
  WORKDIR /app/apps/web
37
41
  USER bun
38
42
  EXPOSE 3000
39
43
  HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
40
- CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
44
+ CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
41
45
  CMD ["bun", "./build"]
@@ -0,0 +1,116 @@
1
+ import { ApiError } from "@podosoft/podokit-api-client";
2
+ import type { RequestEvent } from "@sveltejs/kit";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { BackendUnavailableError, serverApiClient } from "./api";
5
+ import { backendReachable, checkBackend, reportBackendAnswer } from "./backend-proxy";
6
+
7
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
8
+
9
+ function requestEvent(fetch: Fetch): RequestEvent {
10
+ return {
11
+ request: new Request("http://app.localhost/services", { headers: { cookie: "session=abc" } }),
12
+ getClientAddress: () => "203.0.113.9",
13
+ fetch,
14
+ } as unknown as RequestEvent;
15
+ }
16
+
17
+ // Never answers on its own, like a blackholed connection or a hung API, and
18
+ // rejects when its signal aborts.
19
+ const hang: Fetch = (_input, init) =>
20
+ new Promise((_settle, reject) => {
21
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
22
+ });
23
+ const refuse: Fetch = async () => {
24
+ throw new TypeError("Unable to connect. Is the computer able to access the url?");
25
+ };
26
+
27
+ // The background GET /health check uses the global fetch.
28
+ function stubHealth(health: Fetch): ReturnType<typeof vi.fn<Fetch>> {
29
+ const fetch = vi.fn<Fetch>(health);
30
+ vi.stubGlobal("fetch", fetch);
31
+ return fetch;
32
+ }
33
+
34
+ describe("serverApiClient", () => {
35
+ afterEach(() => {
36
+ reportBackendAnswer();
37
+ vi.unstubAllGlobals();
38
+ vi.unstubAllEnvs();
39
+ });
40
+
41
+ it("forwards the cookie and client address", async () => {
42
+ let headers: Headers | undefined;
43
+ const api = serverApiClient(requestEvent(async (_input, init) => {
44
+ headers = new Headers(init?.headers);
45
+ return Response.json({ ok: true });
46
+ }));
47
+
48
+ await expect(api.get("/site/settings")).resolves.toEqual({ ok: true });
49
+ expect(headers?.get("cookie")).toBe("session=abc");
50
+ expect(headers?.get("x-forwarded-for")).toBe("203.0.113.9");
51
+ });
52
+
53
+ it("gives up on a call that outlasts BACKEND_TIMEOUT_MS", async () => {
54
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
55
+ stubHealth(async () => new Response("ok"));
56
+ const api = serverApiClient(requestEvent(hang));
57
+
58
+ const started = Date.now();
59
+ await expect(api.get("/reports/slow")).rejects.toMatchObject({ name: "TimeoutError" });
60
+ expect(Date.now() - started).toBeLessThan(5_000);
61
+ await checkBackend();
62
+ expect(backendReachable()).toBe(true);
63
+ });
64
+
65
+ it("marks the API unreachable when its health check fails as well", async () => {
66
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
67
+ stubHealth(hang);
68
+
69
+ await expect(serverApiClient(requestEvent(hang)).get("/site/settings")).rejects.toMatchObject({
70
+ name: "TimeoutError",
71
+ });
72
+ await checkBackend();
73
+ expect(backendReachable()).toBe(false);
74
+ });
75
+
76
+ it("fails at once without contacting the API while it is unreachable", async () => {
77
+ stubHealth(refuse);
78
+ await checkBackend();
79
+ const fetch = vi.fn<Fetch>(async () => Response.json({}));
80
+
81
+ await expect(serverApiClient(requestEvent(fetch)).get("/blog")).rejects.toBeInstanceOf(BackendUnavailableError);
82
+ expect(fetch).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("fails calls in flight once the API is found unreachable", async () => {
86
+ stubHealth(refuse);
87
+ const pending = serverApiClient(requestEvent(hang)).get("/site/settings");
88
+ const failed = expect(pending).rejects.toBeInstanceOf(BackendUnavailableError);
89
+
90
+ await checkBackend();
91
+
92
+ await failed;
93
+ });
94
+
95
+ it("treats an error status as an answer from a reachable API", async () => {
96
+ const health = stubHealth(refuse);
97
+ const api = serverApiClient(requestEvent(async () =>
98
+ Response.json({ success: false, error: { code: "INTERNAL", message: "boom", statusCode: 500 } }, { status: 500 })
99
+ ));
100
+
101
+ await expect(api.get("/blog")).rejects.toBeInstanceOf(ApiError);
102
+ expect(health).not.toHaveBeenCalled();
103
+ expect(backendReachable()).toBe(true);
104
+ });
105
+
106
+ it("does not report a call the caller aborted as an outage", async () => {
107
+ const health = stubHealth(refuse);
108
+ const caller = new AbortController();
109
+ const pending = serverApiClient(requestEvent(hang)).get("/blog", { signal: caller.signal });
110
+ caller.abort();
111
+
112
+ await expect(pending).rejects.toBeDefined();
113
+ expect(health).not.toHaveBeenCalled();
114
+ expect(backendReachable()).toBe(true);
115
+ });
116
+ });
@@ -1,18 +1,48 @@
1
1
  import type { RequestEvent } from "@sveltejs/kit";
2
2
  import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
- import { backendBaseUrl, resolveClientIp } from "./backend-proxy";
3
+ import {
4
+ backendBaseUrl,
5
+ backendOutageSignal,
6
+ backendReachable,
7
+ backendTimeoutMs,
8
+ reportBackendAnswer,
9
+ reportBackendFailure,
10
+ resolveClientIp,
11
+ } from "./backend-proxy";
12
+
13
+ /** Thrown without contacting the API while it is known to be unreachable. */
14
+ export class BackendUnavailableError extends Error {
15
+ constructor() {
16
+ super("The API is unavailable");
17
+ this.name = "BackendUnavailableError";
18
+ }
19
+ }
4
20
 
5
21
  // Server-side client for load functions and hooks: points at the internal
6
- // backend URL and forwards the incoming request's cookies.
22
+ // backend URL and forwards the incoming request's cookies. Each call is bounded
23
+ // by BACKEND_TIMEOUT_MS, so a page renders its fallbacks instead of waiting for
24
+ // an API that does not answer.
7
25
  export function serverApiClient(event: RequestEvent): ApiClient {
8
26
  const cookie = event.request.headers.get("cookie") ?? "";
9
27
  const clientIp = resolveClientIp(event.getClientAddress);
10
28
  const fetchWithCookies: typeof fetch = Object.assign(
11
- (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
29
+ async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
30
+ if (!backendReachable()) throw new BackendUnavailableError();
12
31
  const headers = new Headers(init?.headers);
13
32
  if (cookie) headers.set("cookie", cookie);
14
33
  if (clientIp) headers.set("x-forwarded-for", clientIp);
15
- return event.fetch(input, { ...init, headers });
34
+ const outage = backendOutageSignal();
35
+ const signals = [AbortSignal.timeout(backendTimeoutMs()), outage];
36
+ if (init?.signal) signals.push(init.signal);
37
+ try {
38
+ const response = await event.fetch(input, { ...init, headers, signal: AbortSignal.any(signals) });
39
+ reportBackendAnswer();
40
+ return response;
41
+ } catch (cause: unknown) {
42
+ if (outage.aborted) throw new BackendUnavailableError();
43
+ if (!init?.signal?.aborted) void reportBackendFailure();
44
+ throw cause;
45
+ }
16
46
  },
17
47
  { preconnect: fetch.preconnect },
18
48
  );