@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
@@ -357,6 +357,8 @@ function buildDefaultComposeProfile(projectName, options) {
357
357
  CORS_ORIGIN: `https://${host}`,
358
358
  ADDRESS_HEADER: "x-forwarded-for",
359
359
  XFF_DEPTH: "1",
360
+ TRUSTED_PROXY_HOPS: "1",
361
+ TRUSTED_PROXY_HEADER: "x-forwarded-for",
360
362
  },
361
363
  verification: {
362
364
  baseUrl: `https://${host}`,
@@ -418,8 +420,6 @@ function initializeComposeProfile(projectRoot, name, options) {
418
420
  RATE_LIMIT_AUTH_TTL: "60",
419
421
  RATE_LIMIT_AUTH_MAX: "20",
420
422
  RATE_LIMIT_RUNTIME_MAX: "1000",
421
- RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
422
- RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
423
423
  RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
424
424
  RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
425
425
  }
@@ -37,6 +37,7 @@ function derivedRuntimeConfig(profile) {
37
37
  const result = {
38
38
  PORT: String(CONTAINER_PORT),
39
39
  BACKEND_INTERNAL_URL: `http://${serviceName(profile, "api")}:${CONTAINER_PORT}`,
40
+ ...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
40
41
  ...profile.runtimeConfig,
41
42
  };
42
43
  if (profile.dependencies.postgres.mode === "managed") {
@@ -311,7 +312,8 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
311
312
  healthcheckPath: "/health/ready",
312
313
  }));
313
314
  services.push(applicationService(profile, "web", images.web, release, stateDigest, {
314
- healthcheckPath: "/",
315
+ // The web server's own route: an API outage must not mark it unhealthy.
316
+ healthcheckPath: "/healthz",
315
317
  publish: !hasGateway,
316
318
  }));
317
319
  if (hasGateway) {
@@ -397,6 +397,8 @@ function initializeDeploymentProfile(projectRoot, name, options) {
397
397
  CORS_ORIGIN: `https://${host}`,
398
398
  ADDRESS_HEADER: "x-forwarded-for",
399
399
  XFF_DEPTH: "1",
400
+ TRUSTED_PROXY_HOPS: "1",
401
+ TRUSTED_PROXY_HEADER: "x-forwarded-for",
400
402
  ...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
401
403
  ...(modules.has("rate-limit")
402
404
  ? {
@@ -406,8 +408,6 @@ function initializeDeploymentProfile(projectRoot, name, options) {
406
408
  RATE_LIMIT_AUTH_TTL: "60",
407
409
  RATE_LIMIT_AUTH_MAX: "20",
408
410
  RATE_LIMIT_RUNTIME_MAX: "1000",
409
- RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
410
- RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
411
411
  RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
412
412
  RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
413
413
  }
@@ -6,6 +6,7 @@ const node_crypto_1 = require("node:crypto");
6
6
  const node_path_1 = require("node:path");
7
7
  const deploy_profile_1 = require("./deploy-profile");
8
8
  const api_paths_1 = require("./api-paths");
9
+ const deploy_schema_1 = require("./deploy-schema");
9
10
  const lockfile_1 = require("./lockfile");
10
11
  const toolchain_1 = require("./toolchain");
11
12
  function quote(value) {
@@ -356,6 +357,7 @@ function derivedRuntimeConfig(profile) {
356
357
  BACKEND_INTERNAL_URL: `http://${profile.target.release}-api:3000`,
357
358
  PROTOCOL_HEADER: "x-forwarded-proto",
358
359
  HOST_HEADER: "x-forwarded-host",
360
+ ...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
359
361
  ...profile.runtimeConfig,
360
362
  };
361
363
  if (profile.dependencies.postgres.mode === "inCluster") {
@@ -386,8 +388,10 @@ metadata:
386
388
  ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
387
389
  `;
388
390
  }
389
- // Above the API's 800 ms per-check readiness bound; the Kubernetes default is 1 s.
390
- const API_PROBE_TIMEOUT_SECONDS = 2;
391
+ // Every rendered API and web probe allows 2 s instead of the Kubernetes default
392
+ // of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
393
+ // web server.
394
+ const PROBE_TIMEOUT_SECONDS = 2;
391
395
  function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
392
396
  const name = `${profile.target.release}-${kind}`;
393
397
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
@@ -399,25 +403,29 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
399
403
  ? ` readinessProbe:
400
404
  httpGet: { path: /health/ready, port: 3000 }
401
405
  periodSeconds: 5
402
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
406
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
403
407
  livenessProbe:
404
408
  httpGet: { path: /health, port: 3000 }
405
409
  initialDelaySeconds: 20
406
410
  periodSeconds: 10
407
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
411
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
408
412
  startupProbe:
409
413
  httpGet: { path: /health, port: 3000 }
410
414
  failureThreshold: 30
411
415
  periodSeconds: 2
412
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
416
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
413
417
  `
414
- : ` readinessProbe:
415
- httpGet: { path: /, port: 3000 }
418
+ : // The web server's own route: an API outage must not take the web pods
419
+ // out of service or restart them.
420
+ ` readinessProbe:
421
+ httpGet: { path: /healthz, port: 3000 }
416
422
  periodSeconds: 5
423
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
417
424
  livenessProbe:
418
- httpGet: { path: /, port: 3000 }
425
+ httpGet: { path: /healthz, port: 3000 }
419
426
  initialDelaySeconds: 20
420
427
  periodSeconds: 10
428
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
421
429
  `;
422
430
  return `apiVersion: apps/v1
423
431
  kind: Deployment
@@ -50,6 +50,13 @@ export declare function validateImage(value: string, field: string): void;
50
50
  export declare function validateImageRepository(value: string, field: string): void;
51
51
  export declare function parseReleaseProfile(release: Record<string, unknown>): ReleaseProfile;
52
52
  export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
53
+ /**
54
+ * The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
55
+ * (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
56
+ * the fewest seconds that keep a silent connection open past the /api proxy's
57
+ * BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
58
+ */
59
+ export declare function idleTimeoutConfig(runtimeConfig: Record<string, string>): Record<string, string>;
53
60
  export declare function validateHttpsOrigin(value: string): void;
54
61
  export declare function parseVerification(verification: Record<string, unknown>): VerificationProfile;
55
62
  export declare function profileDirectory(projectRoot: string): string;
@@ -17,6 +17,7 @@ exports.validateImage = validateImage;
17
17
  exports.validateImageRepository = validateImageRepository;
18
18
  exports.parseReleaseProfile = parseReleaseProfile;
19
19
  exports.parseRuntimeConfig = parseRuntimeConfig;
20
+ exports.idleTimeoutConfig = idleTimeoutConfig;
20
21
  exports.validateHttpsOrigin = validateHttpsOrigin;
21
22
  exports.parseVerification = parseVerification;
22
23
  exports.profileDirectory = profileDirectory;
@@ -35,6 +36,11 @@ exports.MANAGED_RUNTIME_CONFIG_KEYS = [
35
36
  "PROTOCOL_HEADER",
36
37
  "HOST_HEADER",
37
38
  ];
39
+ /** Keys the generated API no longer reads; it refuses to start while they are set. */
40
+ const RENAMED_RUNTIME_CONFIG_KEYS = {
41
+ RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
42
+ RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
43
+ };
38
44
  function containsUrlCredentials(value) {
39
45
  if (!value.includes("://"))
40
46
  return false;
@@ -183,6 +189,10 @@ function parseRuntimeConfig(runtimeConfig) {
183
189
  if (exports.MANAGED_RUNTIME_CONFIG_KEYS.includes(key)) {
184
190
  throw new Error(`Deployment runtimeConfig key "${key}" is managed by PodoKit.`);
185
191
  }
192
+ const renamed = RENAMED_RUNTIME_CONFIG_KEYS[key];
193
+ if (renamed) {
194
+ throw new Error(`Deployment runtimeConfig key "${key}" was renamed to "${renamed}".`);
195
+ }
186
196
  if (typeof entry !== "string") {
187
197
  throw new Error(`Deployment runtimeConfig value "${key}" must be a string.`);
188
198
  }
@@ -193,6 +203,36 @@ function parseRuntimeConfig(runtimeConfig) {
193
203
  }
194
204
  return parsed;
195
205
  }
206
+ /** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
207
+ const IDLE_TICK_SECONDS = 4;
208
+ const MAX_IDLE_TIMEOUT_SECONDS = 255;
209
+ const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
210
+ /**
211
+ * The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
212
+ * (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
213
+ * the fewest seconds that keep a silent connection open past the /api proxy's
214
+ * BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
215
+ */
216
+ function idleTimeoutConfig(runtimeConfig) {
217
+ const proxyTimeout = runtimeConfig.BACKEND_PROXY_TIMEOUT_MS?.trim();
218
+ const proxyMs = proxyTimeout && /^\d+$/.test(proxyTimeout) ? Number(proxyTimeout) : DEFAULT_BACKEND_PROXY_TIMEOUT_MS;
219
+ const derived = Math.floor(proxyMs / 1000) + IDLE_TICK_SECONDS + 1;
220
+ const config = {};
221
+ for (const name of ["CONNECTION_IDLE_TIMEOUT", "API_IDLE_TIMEOUT"]) {
222
+ const configured = runtimeConfig[name];
223
+ if (configured !== undefined) {
224
+ config[name] = configured;
225
+ }
226
+ else if (derived > MAX_IDLE_TIMEOUT_SECONDS) {
227
+ throw new Error(`Deployment runtimeConfig BACKEND_PROXY_TIMEOUT_MS ${proxyMs} outlasts the longest idle timeout ` +
228
+ `(${MAX_IDLE_TIMEOUT_SECONDS} seconds); set ${name} to "0" to turn it off.`);
229
+ }
230
+ else {
231
+ config[name] = String(derived);
232
+ }
233
+ }
234
+ return config;
235
+ }
196
236
  function validateHttpsOrigin(value) {
197
237
  let parsed;
198
238
  try {
@@ -82,7 +82,14 @@ export declare function podokitVersion(): string;
82
82
  export declare function matchGlob(path: string, glob: string): boolean;
83
83
  /** Classify a file's ownership tier. See ADR-0010 priority order. */
84
84
  export declare function classifyTier(relPath: string, content: string | Buffer, ownedGlobs: string[], managedOverrides?: string[]): Tier;
85
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
85
+ /**
86
+ * Whether a path is a local environment file such as `.env`, `.env.docker` or
87
+ * `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
88
+ * never records them; the committed `*.example` files are project files.
89
+ */
90
+ export declare function isLocalEnvFile(relPath: string): boolean;
91
+ /** List project files as POSIX-relative paths, skipping generated/vendored dirs
92
+ * and local environment files. */
86
93
  export declare function walkFiles(projectRoot: string, prefix?: string): string[];
87
94
  /** Recompute the files.lock by hashing and classifying every project file. */
88
95
  export declare function computeFilesLock(projectRoot: string, ownedGlobs: string[], managedOverrides?: string[]): FilesLock;
package/dist/lockfile.js CHANGED
@@ -11,6 +11,7 @@ exports.writeFilesLock = writeFilesLock;
11
11
  exports.podokitVersion = podokitVersion;
12
12
  exports.matchGlob = matchGlob;
13
13
  exports.classifyTier = classifyTier;
14
+ exports.isLocalEnvFile = isLocalEnvFile;
14
15
  exports.walkFiles = walkFiles;
15
16
  exports.computeFilesLock = computeFilesLock;
16
17
  exports.readFilesLock = readFilesLock;
@@ -54,9 +55,11 @@ exports.DEFAULT_OWNED_GLOBS = [
54
55
  "apps/web/src/lib/i18n/catalogs/app/**",
55
56
  // Owned Elysia extension slot — register application services and plugins here.
56
57
  "apps/api/src/app.extensions.ts",
57
- // Owned additions to the managed /api proxy's header lists and body limits.
58
+ // Owned additions to the managed /api proxy's header lists, body limits, and
59
+ // response deadlines.
58
60
  "apps/web/src/lib/server/proxy-headers.ts",
59
61
  "apps/web/src/lib/server/body-limits.ts",
62
+ "apps/web/src/lib/server/proxy-timeouts.ts",
60
63
  // Owned fallback page SvelteKit serves when the root layout or a hook fails.
61
64
  "apps/web/src/error.html",
62
65
  // AI agent guidance — yours to customize; update never touches it.
@@ -168,7 +171,17 @@ function classifyTier(relPath, content, ownedGlobs, managedOverrides = []) {
168
171
  return "assembled";
169
172
  return "managed";
170
173
  }
171
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
174
+ /**
175
+ * Whether a path is a local environment file such as `.env`, `.env.docker` or
176
+ * `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
177
+ * never records them; the committed `*.example` files are project files.
178
+ */
179
+ function isLocalEnvFile(relPath) {
180
+ const name = relPath.slice(relPath.lastIndexOf("/") + 1);
181
+ return (name === ".env" || name.startsWith(".env.")) && !name.endsWith(".example");
182
+ }
183
+ /** List project files as POSIX-relative paths, skipping generated/vendored dirs
184
+ * and local environment files. */
172
185
  function walkFiles(projectRoot, prefix = "") {
173
186
  const out = [];
174
187
  for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, prefix))) {
@@ -178,7 +191,7 @@ function walkFiles(projectRoot, prefix = "") {
178
191
  if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectRoot, rel)).isDirectory()) {
179
192
  out.push(...walkFiles(projectRoot, rel));
180
193
  }
181
- else {
194
+ else if (!isLocalEnvFile(rel)) {
182
195
  out.push(rel);
183
196
  }
184
197
  }
@@ -198,7 +211,13 @@ function computeFilesLock(projectRoot, ownedGlobs, managedOverrides = []) {
198
211
  }
199
212
  function readFilesLock(projectRoot) {
200
213
  const file = filesLockPath(projectRoot);
201
- return (0, node_fs_1.existsSync)(file) ? JSON.parse((0, node_fs_1.readFileSync)(file, "utf8")) : null;
214
+ if (!(0, node_fs_1.existsSync)(file))
215
+ return null;
216
+ const lock = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
217
+ // Earlier versions recorded local env files. Leaving them out here keeps
218
+ // update from treating them as PodoKit files, and the next write drops them.
219
+ const files = Object.entries(lock.files).filter(([path]) => !isLocalEnvFile(path));
220
+ return { ...lock, files: Object.fromEntries(files) };
202
221
  }
203
222
  /**
204
223
  * Compare the current disk against the lock: report managed/assembled files the
@@ -18,6 +18,7 @@ export interface ApplyProviderChangeOptions {
18
18
  modulesDir?: string;
19
19
  podokitVersion?: string;
20
20
  }
21
+ /** The managed provider source; it must render byte for byte like the template's copy. */
21
22
  export declare function providerSource(providers: ProviderSelections): string;
22
23
  export declare function listProviders(projectRoot: string): ProviderListItem[];
23
24
  export declare function planProviderChange(projectRoot: string, capabilityInput: string, provider: string, modulesDir?: string): ProviderChangePlan;
package/dist/provider.js CHANGED
@@ -27,15 +27,18 @@ function assertProvider(capability, provider) {
27
27
  }
28
28
  return capability;
29
29
  }
30
+ /** The managed provider source; it must render byte for byte like the template's copy. */
30
31
  function providerSource(providers) {
31
32
  return `import type { ProviderSelections } from "@podosoft/podokit-runtime";\n\n` +
32
- `export const PROVIDERS = {\n` +
33
+ `// Typed with every provider name, not the selected ones: the module of a\n` +
34
+ `// provider that stays installed after a switch compares against its own name.\n` +
35
+ `export const PROVIDERS: Readonly<ProviderSelections> = {\n` +
33
36
  ` database: "${providers.database}",\n` +
34
37
  ` cache: "${providers.cache}",\n` +
35
38
  ` "object-storage": "${providers["object-storage"]}",\n` +
36
39
  ` events: "${providers.events}",\n` +
37
40
  ` jobs: "${providers.jobs}",\n` +
38
- `} as const satisfies ProviderSelections;\n`;
41
+ `};\n`;
39
42
  }
40
43
  function listProviders(projectRoot) {
41
44
  const manifest = requireManifest(projectRoot);
@@ -49,7 +49,32 @@ Merge added module settings from `.env.example` into an existing `.env`.
49
49
  managed lists. Cap a route's request body in the app-owned
50
50
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
51
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
- uploads larger than it must use a direct API path (`apiPaths`) instead.
52
+ uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
53
+ route a longer, shorter, or no response deadline in the app-owned
54
+ `apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
55
+ none); never edit the managed deadline. Both lists take path prefixes with
56
+ `:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
57
+ specific prefix wins.
58
+ - Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
59
+ (2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
60
+ response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
61
+ `GET /health` stops answering too, calls throw `BackendUnavailableError` and the
62
+ proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
63
+ any non-`ApiError` failure in a load function as a temporary outage. The API
64
+ (`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
65
+ a connection that sends nothing for that many seconds (35 each): raise both
66
+ with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
67
+ than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
68
+ load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
69
+ - Read a caller's address in the API with `clientAddressOf(request.headers)` from
70
+ `apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
71
+ API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
72
+ `TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
73
+ the same address.
74
+ - `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
75
+ health route for the Kubernetes probes, the Compose healthcheck, and the image
76
+ `HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
77
+ out of service.
53
78
  - When the root layout or a server hook fails, such as with 503 while the API is
54
79
  down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
55
80
  with inline styles: no components, app CSS, scripts, or assets, and only the
@@ -96,7 +121,7 @@ changes need an e2e/unit test.
96
121
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
97
122
  in `.podokit/files.lock` — respect it:
98
123
 
99
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` sets the body limit when the export leaves it out.
124
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
100
125
  - **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
101
126
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
102
127
 
@@ -131,7 +131,7 @@ Run each service in a separate terminal from the project root:
131
131
  ```
132
132
 
133
133
  - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
134
- - Web: http://localhost:5001
134
+ - Web: http://localhost:5001 — its own health at `/healthz`, which never calls the API
135
135
 
136
136
  ## Database & migrations
137
137
 
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@podosoft/podokit-contracts": "^0.5.0",
20
- "@podosoft/podokit-runtime": "^0.4.0",
20
+ "@podosoft/podokit-runtime": "^0.4.1",
21
21
  "@elysia/cors": "^1.4.2",
22
22
  "@elysia/openapi": "^1.4.15",
23
23
  "dotenv": "^16.4.5",
@@ -16,7 +16,11 @@ if (!target) {
16
16
  process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
17
17
  process.exit(1);
18
18
  }
19
+ // Run in the API directory wherever this was started from: Bun reads decorator
20
+ // settings from the tsconfig.json of the working directory, and the data source
21
+ // finds .env and the migrations relative to it.
19
22
  const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
23
+ cwd: fileURLToPath(new URL("..", import.meta.url)),
20
24
  stdin: "inherit",
21
25
  stdout: "inherit",
22
26
  stderr: "inherit",
@@ -12,6 +12,11 @@ export const extensionModules: PodokitModule[] = [];
12
12
 
13
13
  // Bun server options such as a larger request body limit for uploads, for
14
14
  // example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
15
- // win over API_MAX_REQUEST_BODY_SIZE; a function receives the validated
16
- // environment instead: (env) => ({ maxRequestBodySize: ... }).
15
+ // win over API_MAX_REQUEST_BODY_SIZE and API_IDLE_TIMEOUT; a function receives
16
+ // the validated environment instead: (env) => ({ maxRequestBodySize: ... }). A
17
+ // longer idleTimeout also needs a longer proxy deadline on the web server, which
18
+ // stops waiting for response headers after 30 seconds by default:
19
+ // BACKEND_PROXY_TIMEOUT_MS, or one route's entry in APP_PROXY_TIMEOUTS
20
+ // (apps/web/src/lib/server/proxy-timeouts.ts), and the web server's
21
+ // CONNECTION_IDLE_TIMEOUT (35 seconds).
17
22
  export const apiServerOptions: ApiServerOptionsSource = {};
@@ -4,6 +4,7 @@ import { AppException, type ErrorEnvelope } from "@podosoft/podokit-contracts";
4
4
  import { Elysia } from "elysia";
5
5
  import { configureServices, extensionModules } from "./app.extensions";
6
6
  import { validateEnv, type AppEnv } from "./config/env.validation";
7
+ import { stampClientAddress } from "./core/client-address";
7
8
  import {
8
9
  appContext,
9
10
  createCoreServices,
@@ -137,6 +138,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
137
138
  services.freeze();
138
139
  const context = appContext(env, services);
139
140
  const corsOrigin = env.CORS_ORIGIN?.split(",").map((origin) => origin.trim());
141
+ const clientAddressConfig = { trustedProxyHops: env.TRUSTED_PROXY_HOPS, header: env.TRUSTED_PROXY_HEADER };
140
142
 
141
143
  const app = new Elysia({ name: "podokit" })
142
144
  .use(cors({ origin: corsOrigin ?? true, credentials: true }))
@@ -157,11 +159,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
157
159
  .onRequest(async ({ request, server, set }) => {
158
160
  const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
159
161
  set.headers["x-request-id"] = requestId;
162
+ const remoteAddress = server?.requestIP(request)?.address;
163
+ const clientAddress = stampClientAddress(request, remoteAddress, clientAddressConfig);
160
164
  await context.requestGuards.run({
161
165
  request,
162
- ...(server?.requestIP(request)?.address
163
- ? { remoteAddress: server.requestIP(request)?.address }
164
- : {}),
166
+ ...(remoteAddress ? { remoteAddress } : {}),
167
+ ...(clientAddress ? { clientAddress } : {}),
165
168
  setHeader: (name, value) => { set.headers[name] = value; },
166
169
  });
167
170
  })
@@ -6,6 +6,22 @@ const schema = z.object({
6
6
  PORT: z.coerce.number().default(5002),
7
7
  // Bytes one request body may hold; 128 MiB is Bun's default.
8
8
  API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
9
+ // Seconds a connection may stay silent, 0 (never closed) to 255. Bun closes idle
10
+ // connections on a 4-second sweep, so 35 outlasts the web proxy's 30-second
11
+ // deadline. An empty value means unset rather than 0.
12
+ API_IDLE_TIMEOUT: z.preprocess(
13
+ (value) => (value === "" ? undefined : value),
14
+ z.coerce.number().int().min(0).max(255).default(35),
15
+ ),
16
+ // Reverse proxies in front of the API whose forwarded header names the client;
17
+ // the generated web proxy is one. 0 uses the socket peer.
18
+ TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0),
19
+ TRUSTED_PROXY_HEADER: z
20
+ .string()
21
+ .trim()
22
+ .toLowerCase()
23
+ .regex(/^[a-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header name")
24
+ .default("x-forwarded-for"),
9
25
  CORS_ORIGIN: z.string().optional(),
10
26
  DATABASE_URL: z.string().optional(),
11
27
  POSTGRES_HOST: z.string().default("localhost"),
@@ -24,7 +40,19 @@ const schema = z.object({
24
40
 
25
41
  export type AppEnv = z.infer<typeof schema>;
26
42
 
43
+ // Renamed settings fail loudly: falling back to the default would count and
44
+ // record every browser request under the web server's address.
45
+ const RENAMED: Record<string, string> = {
46
+ RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
47
+ RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
48
+ };
49
+
27
50
  export function validateEnv(config: Record<string, unknown>): AppEnv {
51
+ for (const [previous, current] of Object.entries(RENAMED)) {
52
+ if (config[previous] !== undefined) {
53
+ throw new Error(`Invalid environment: ${previous} was renamed to ${current}.`);
54
+ }
55
+ }
28
56
  const parsed = schema.safeParse(config);
29
57
  if (!parsed.success) {
30
58
  throw new Error(`Invalid environment:\n${parsed.error.toString()}`);
@@ -1,9 +1,11 @@
1
1
  import type { ProviderSelections } from "@podosoft/podokit-runtime";
2
2
 
3
- export const PROVIDERS = {
3
+ // Typed with every provider name, not the selected ones: the module of a
4
+ // provider that stays installed after a switch compares against its own name.
5
+ export const PROVIDERS: Readonly<ProviderSelections> = {
4
6
  database: "{{databaseProvider}}",
5
7
  cache: "{{cacheProvider}}",
6
8
  "object-storage": "{{objectStorageProvider}}",
7
9
  events: "{{eventsProvider}}",
8
10
  jobs: "{{jobsProvider}}",
9
- } as const satisfies ProviderSelections;
11
+ };
@@ -4,26 +4,37 @@ import { validateEnv } from "./env.validation";
4
4
  import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
5
5
 
6
6
  describe("apiServerOptions", () => {
7
- it("defaults to Bun's request body limit and leaves the idle timeout to Elysia", () => {
7
+ it("defaults to Bun's request body limit and an idle timeout that outlasts the web proxy", () => {
8
8
  const env = validateEnv({});
9
9
  expect(env.API_MAX_REQUEST_BODY_SIZE).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE);
10
- expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE });
10
+ expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE, idleTimeout: 35 });
11
+ });
12
+
13
+ it("reads API_IDLE_TIMEOUT from 0 to 255 seconds and treats an empty value as unset", () => {
14
+ expect(validateEnv({ API_IDLE_TIMEOUT: "125" }).API_IDLE_TIMEOUT).toBe(125);
15
+ expect(validateEnv({ API_IDLE_TIMEOUT: "0" }).API_IDLE_TIMEOUT).toBe(0);
16
+ expect(validateEnv({ API_IDLE_TIMEOUT: "255" }).API_IDLE_TIMEOUT).toBe(255);
17
+ expect(validateEnv({ API_IDLE_TIMEOUT: "" }).API_IDLE_TIMEOUT).toBe(35);
18
+ for (const value of ["256", "-1", "1.5", "soon"]) {
19
+ expect(() => validateEnv({ API_IDLE_TIMEOUT: value })).toThrow("API_IDLE_TIMEOUT");
20
+ }
21
+ expect(apiServerOptions(validateEnv({ API_IDLE_TIMEOUT: "0" }), {}).idleTimeout).toBe(0);
11
22
  });
12
23
 
13
24
  it("reads API_MAX_REQUEST_BODY_SIZE and rejects values that are not byte counts", () => {
14
- expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}))
15
- .toEqual({ maxRequestBodySize: 1_073_741_824 });
25
+ expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}).maxRequestBodySize)
26
+ .toBe(1_073_741_824);
16
27
  for (const value of ["0", "-1", "1.5", "lots"]) {
17
28
  expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
18
29
  }
19
30
  });
20
31
 
21
32
  it("lets the application's options win over the environment", () => {
22
- const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
33
+ const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024", API_IDLE_TIMEOUT: "60" });
23
34
  expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
24
35
  .toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
25
36
  expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
26
- .toEqual({ maxRequestBodySize: 2048 });
37
+ .toEqual({ maxRequestBodySize: 2048, idleTimeout: 60 });
27
38
  expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
28
39
  expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
29
40
  expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
@@ -11,7 +11,7 @@ export interface ApiServerOptions {
11
11
  maxRequestBodySize?: number;
12
12
  /**
13
13
  * Seconds a connection may stay idle before it is closed, from 0 (never) to
14
- * 255. Defaults to Elysia's 30 seconds.
14
+ * 255. Defaults to `API_IDLE_TIMEOUT`.
15
15
  */
16
16
  idleTimeout?: number;
17
17
  }
@@ -31,18 +31,15 @@ const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: Api
31
31
  export function apiServerOptions(
32
32
  env: AppEnv,
33
33
  source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
34
- ): ApiServerOptions & { maxRequestBodySize: number } {
34
+ ): Required<ApiServerOptions> {
35
35
  const options = typeof source === "function" ? source(env) : source ?? {};
36
36
  const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
37
37
  if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
38
38
  throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
39
39
  }
40
- const { idleTimeout } = options;
41
- if (
42
- idleTimeout !== undefined &&
43
- (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
44
- ) {
40
+ const idleTimeout = options.idleTimeout ?? env.API_IDLE_TIMEOUT;
41
+ if (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS) {
45
42
  throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
46
43
  }
47
- return { maxRequestBodySize, ...(idleTimeout === undefined ? {} : { idleTimeout }) };
44
+ return { maxRequestBodySize, idleTimeout };
48
45
  }