@podosoft/podokit 1.3.0 → 1.4.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 (145) hide show
  1. package/dist/add.d.ts +10 -1
  2. package/dist/add.js +20 -7
  3. package/dist/api-paths.d.ts +34 -0
  4. package/dist/api-paths.js +103 -0
  5. package/dist/deploy-compose-profile.d.ts +4 -0
  6. package/dist/deploy-compose-profile.js +13 -2
  7. package/dist/deploy-compose-render.d.ts +1 -1
  8. package/dist/deploy-compose-render.js +23 -7
  9. package/dist/deploy-compose.js +2 -1
  10. package/dist/deploy-profile.d.ts +3 -0
  11. package/dist/deploy-profile.js +12 -4
  12. package/dist/deploy-render.js +54 -8
  13. package/dist/deploy.js +21 -1
  14. package/dist/dev.d.ts +14 -0
  15. package/dist/dev.js +152 -10
  16. package/dist/index.js +15 -2
  17. package/dist/lib.d.ts +2 -0
  18. package/dist/lib.js +4 -2
  19. package/dist/lockfile.d.ts +3 -0
  20. package/dist/lockfile.js +9 -0
  21. package/dist/module-api-paths.d.ts +52 -0
  22. package/dist/module-api-paths.js +189 -0
  23. package/dist/remove.d.ts +3 -0
  24. package/dist/remove.js +12 -3
  25. package/dist/templates/fullstack/AGENTS.md +17 -2
  26. package/dist/templates/fullstack/README.md +19 -5
  27. package/dist/templates/fullstack/apps/api/package.json +1 -1
  28. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  29. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  30. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  31. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  32. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  34. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  36. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  38. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  39. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  40. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  41. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  42. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  43. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  44. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  45. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  46. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  47. package/dist/templates/fullstack/apps/web/components.json +3 -1
  48. package/dist/templates/fullstack/apps/web/package.json +1 -1
  49. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  50. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  51. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  52. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  53. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  55. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  56. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  57. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  58. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  59. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  60. package/dist/templates/fullstack/dot-env.example +10 -0
  61. package/dist/templates/fullstack/dot-gitignore +1 -0
  62. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  63. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  64. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  71. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  72. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  73. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  87. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  88. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  89. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  90. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  91. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  92. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  93. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  94. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  95. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  96. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  97. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  98. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  99. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  100. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  101. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  102. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  103. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  104. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  105. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  106. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  107. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  108. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  109. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  110. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  111. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  112. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  113. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  114. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  115. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  116. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  117. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  118. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  119. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  120. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  121. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  122. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  124. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  125. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  126. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  127. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  128. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  129. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  130. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  131. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  132. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  133. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  134. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  135. package/dist/templates/modules/redis/module.manifest.json +4 -0
  136. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  137. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  138. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  139. package/dist/templates/todo/AGENTS.md +9 -1
  140. package/dist/templates/todo/README.md +18 -5
  141. package/dist/update.d.ts +5 -0
  142. package/dist/update.js +35 -0
  143. package/package.json +2 -2
  144. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  145. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
package/dist/add.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
2
  import { type ProviderCapability, type ProviderSelections } from "@podosoft/podokit-runtime";
3
+ import { type ApiPathResult } from "./module-api-paths";
3
4
  interface Injection {
4
5
  file: string;
5
6
  marker: string;
@@ -9,7 +10,7 @@ interface Injection {
9
10
  }
10
11
  /** Highest `module.manifest.json` schema version this CLI understands. A package
11
12
  * module declaring a higher version is rejected rather than mis-applied. */
12
- export declare const SUPPORTED_MANIFEST_VERSION = 3;
13
+ export declare const SUPPORTED_MANIFEST_VERSION = 4;
13
14
  export interface ModuleRuntimeConstraints {
14
15
  /** Maximum safe API replicas for process-local providers. */
15
16
  maxApiReplicas?: number;
@@ -79,6 +80,12 @@ export interface ModuleManifest {
79
80
  requiredProjectFiles?: string[];
80
81
  /** Stable source contracts that must be present in generated baseline files. */
81
82
  requiredProjectContents?: Record<string, string[]>;
83
+ /**
84
+ * Path prefixes under /api/ that the gateway must send straight to the API,
85
+ * such as routes taking bodies above the web server's BODY_SIZE_LIMIT. Merged
86
+ * into `.podokit/dev.json` and every deployment profile.
87
+ */
88
+ apiPaths?: string[];
82
89
  }
83
90
  export interface PackageOverlay {
84
91
  dependencies?: Record<string, string>;
@@ -111,6 +118,8 @@ export interface AddResult {
111
118
  adopted: string[];
112
119
  /** Files shipped, merged, or injected while applying the module. */
113
120
  touched: string[];
121
+ /** Direct API paths merged into `.podokit/dev.json` and deployment profiles. */
122
+ apiPaths: ApiPathResult;
114
123
  }
115
124
  export interface ResolvedModule {
116
125
  dir: string;
package/dist/add.js CHANGED
@@ -14,9 +14,11 @@ const node_module_1 = require("node:module");
14
14
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
15
15
  const podokit_runtime_1 = require("@podosoft/podokit-runtime");
16
16
  const lockfile_1 = require("./lockfile");
17
+ const api_paths_1 = require("./api-paths");
18
+ const module_api_paths_1 = require("./module-api-paths");
17
19
  /** Highest `module.manifest.json` schema version this CLI understands. A package
18
20
  * module declaring a higher version is rejected rather than mis-applied. */
19
- exports.SUPPORTED_MANIFEST_VERSION = 3;
21
+ exports.SUPPORTED_MANIFEST_VERSION = 4;
20
22
  /** Return the legacy targetApp package fields plus any additional app overlays. */
21
23
  function modulePackageOverlays(manifest) {
22
24
  const overlays = new Map();
@@ -101,6 +103,12 @@ function assertManifestVersion(name, manifest) {
101
103
  throw new Error(`Module "${name}" declares invalid provider ${capability}=${String(provider)}.`);
102
104
  }
103
105
  }
106
+ try {
107
+ (0, api_paths_1.parseApiPaths)(manifest.apiPaths ?? [], "apiPaths", []);
108
+ }
109
+ catch (error) {
110
+ throw new Error(`Module "${name}" declares invalid apiPaths. ${error.message}`);
111
+ }
104
112
  const maxApiReplicas = manifest.runtimeConstraints?.maxApiReplicas;
105
113
  if (maxApiReplicas !== undefined
106
114
  && (!Number.isSafeInteger(maxApiReplicas) || maxApiReplicas < 1)) {
@@ -363,16 +371,20 @@ function addModule(options) {
363
371
  .map(([path]) => path)
364
372
  : [];
365
373
  const placement = modulePlacement(options.projectRoot, options.module, options.modulesDir);
366
- const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false, placement);
367
- if (options.adopt && result.adopted.length) {
368
- releaseOwnedPaths(options.projectRoot, result.adopted);
374
+ const applied = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false, placement);
375
+ if (options.adopt && applied.adopted.length) {
376
+ releaseOwnedPaths(options.projectRoot, applied.adopted);
369
377
  }
370
378
  // Record the module (and any auto-added requirements), fold in the modules'
371
379
  // declared ownedGlobs, and refresh the lock.
372
- const records = [...result.added, result.module]
380
+ const records = [...applied.added, applied.module]
373
381
  .map((name) => resolveModule(name, options.modulesDir, options.projectRoot))
374
382
  .filter((module) => module !== null)
375
383
  .map(moduleRecord);
384
+ const result = {
385
+ ...applied,
386
+ apiPaths: (0, module_api_paths_1.addApiPaths)(options.projectRoot, records.flatMap((record) => (record.appliedApiPaths ?? []).map((path) => ({ module: record.name, path })))),
387
+ };
376
388
  (0, lockfile_1.recordModules)(options.projectRoot, records, options.podokitVersion, result.ownedGlobs, previousLock
377
389
  ? {
378
390
  previous: previousLock,
@@ -488,12 +500,13 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt, placement)
488
500
  };
489
501
  }
490
502
  function moduleRecord(module) {
491
- const migrations = readModuleManifest(module.dir).migrations ?? [];
503
+ const manifest = readModuleManifest(module.dir);
492
504
  return {
493
505
  name: module.name,
494
506
  packageName: module.packageName,
495
507
  moduleVersion: module.moduleVersion,
496
- appliedMigrations: migrations.map((migration) => migration.id),
508
+ appliedMigrations: (manifest.migrations ?? []).map((migration) => migration.id),
509
+ ...(manifest.apiPaths?.length ? { appliedApiPaths: manifest.apiPaths } : {}),
497
510
  };
498
511
  }
499
512
  /** Copy a module overlay without clobbering app-owned presentation files. */
@@ -0,0 +1,34 @@
1
+ /** The prefix the gateway removes before a direct request reaches the API. */
2
+ export declare const API_PATH_PREFIX = "/api";
3
+ /** The path an entry names, without its optional trailing slash. */
4
+ export declare function apiPathBase(path: string): string;
5
+ /** The entry in `paths` that already routes `path` directly, if any. */
6
+ export declare function coveringApiPath(paths: readonly string[], path: string): string | undefined;
7
+ /**
8
+ * Validate the direct API path allowlist. Each entry covers its own path and
9
+ * every path below it, and must stay under /api/ outside the authentication
10
+ * routes, because the gateway removes /api before the request reaches the API.
11
+ */
12
+ export declare function parseApiPaths(value: unknown, field: string, webSocketPaths: readonly string[]): string[];
13
+ export interface ApiPathMerge {
14
+ /** The resulting list: the current entries followed by the added ones. */
15
+ paths: string[];
16
+ added: string[];
17
+ /** Paths left out because the list would no longer validate. */
18
+ rejected: {
19
+ path: string;
20
+ reason: string;
21
+ }[];
22
+ }
23
+ /**
24
+ * Append `additions` to `current`, skipping paths an entry already covers and
25
+ * paths that would make `validate` throw, such as an entry covering an existing
26
+ * one or a WebSocket path.
27
+ */
28
+ export declare function mergeApiPaths(current: readonly string[], additions: readonly string[], validate: (paths: string[]) => void): ApiPathMerge;
29
+ /** The direct API paths installed modules contributed, as a valid list for a new configuration. */
30
+ export declare function installedApiPaths(modules: readonly {
31
+ appliedApiPaths?: string[];
32
+ }[]): string[];
33
+ /** Plan text naming the paths that reach the API without the web proxy. */
34
+ export declare function directApiRoutes(apiPaths: readonly string[]): string;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.API_PATH_PREFIX = void 0;
4
+ exports.apiPathBase = apiPathBase;
5
+ exports.coveringApiPath = coveringApiPath;
6
+ exports.parseApiPaths = parseApiPaths;
7
+ exports.mergeApiPaths = mergeApiPaths;
8
+ exports.installedApiPaths = installedApiPaths;
9
+ exports.directApiRoutes = directApiRoutes;
10
+ const MAX_PATHS = 32;
11
+ const MAX_PATH_LENGTH = 2_048;
12
+ const API_PATH = /^\/api(?:\/[A-Za-z0-9._~-]+)+\/?$/;
13
+ /** The prefix the gateway removes before a direct request reaches the API. */
14
+ exports.API_PATH_PREFIX = "/api";
15
+ /** The path an entry names, without its optional trailing slash. */
16
+ function apiPathBase(path) {
17
+ return path.endsWith("/") ? path.slice(0, -1) : path;
18
+ }
19
+ /** Whether `base` covers `path`: the same path or any path below it. */
20
+ function covers(base, path) {
21
+ return path === base || path.startsWith(`${base}/`);
22
+ }
23
+ /** The entry in `paths` that already routes `path` directly, if any. */
24
+ function coveringApiPath(paths, path) {
25
+ const base = apiPathBase(path);
26
+ return paths.find((entry) => covers(apiPathBase(entry), base));
27
+ }
28
+ /**
29
+ * Validate the direct API path allowlist. Each entry covers its own path and
30
+ * every path below it, and must stay under /api/ outside the authentication
31
+ * routes, because the gateway removes /api before the request reaches the API.
32
+ */
33
+ function parseApiPaths(value, field, webSocketPaths) {
34
+ if (!Array.isArray(value)) {
35
+ throw new Error(`Configuration field "${field}" must be an array of path prefixes under /api/.`);
36
+ }
37
+ if (value.length > MAX_PATHS) {
38
+ throw new Error(`Configuration field "${field}" must contain at most ${MAX_PATHS} paths.`);
39
+ }
40
+ const paths = [];
41
+ for (const path of value) {
42
+ if (typeof path !== "string" ||
43
+ path.length > MAX_PATH_LENGTH ||
44
+ !API_PATH.test(path) ||
45
+ path.split("/").some((segment) => segment === "." || segment === "..")) {
46
+ throw new Error(`Configuration field "${field}" must contain only static path prefixes under /api/, such as "/api/files/uploads/". /api/ itself, patterns, queries, fragments, encoded characters, empty segments, and traversal segments are not allowed.`);
47
+ }
48
+ const base = apiPathBase(path);
49
+ if (covers("/api/auth", base)) {
50
+ throw new Error(`Configuration field "${field}" cannot list "${path}": the API serves /api/auth/ with its prefix, so authentication routes always go through the web proxy.`);
51
+ }
52
+ const overlap = paths.find((other) => {
53
+ const otherBase = apiPathBase(other);
54
+ return covers(otherBase, base) || covers(base, otherBase);
55
+ });
56
+ if (overlap !== undefined) {
57
+ throw new Error(overlap === path
58
+ ? `Configuration field "${field}" contains duplicate path "${path}".`
59
+ : `Configuration field "${field}" contains overlapping paths "${overlap}" and "${path}".`);
60
+ }
61
+ const socket = webSocketPaths.find((socketPath) => covers(base, socketPath));
62
+ if (socket !== undefined) {
63
+ throw new Error(`Configuration field "${field}" entry "${path}" covers WebSocket path "${socket}", which goes to the API without removing /api.`);
64
+ }
65
+ paths.push(path);
66
+ }
67
+ return paths;
68
+ }
69
+ /**
70
+ * Append `additions` to `current`, skipping paths an entry already covers and
71
+ * paths that would make `validate` throw, such as an entry covering an existing
72
+ * one or a WebSocket path.
73
+ */
74
+ function mergeApiPaths(current, additions, validate) {
75
+ const paths = [...current];
76
+ const added = [];
77
+ const rejected = [];
78
+ for (const path of additions) {
79
+ if (coveringApiPath(paths, path) !== undefined)
80
+ continue;
81
+ const candidate = [...paths, path];
82
+ try {
83
+ validate(candidate);
84
+ }
85
+ catch (error) {
86
+ rejected.push({ path, reason: error.message });
87
+ continue;
88
+ }
89
+ paths.push(path);
90
+ added.push(path);
91
+ }
92
+ return { paths, added, rejected };
93
+ }
94
+ /** The direct API paths installed modules contributed, as a valid list for a new configuration. */
95
+ function installedApiPaths(modules) {
96
+ return mergeApiPaths([], modules.flatMap((module) => module.appliedApiPaths ?? []), (paths) => parseApiPaths(paths, "apiPaths", [])).paths;
97
+ }
98
+ /** Plan text naming the paths that reach the API without the web proxy. */
99
+ function directApiRoutes(apiPaths) {
100
+ return apiPaths.length
101
+ ? ` Route ${apiPaths.join(", ")} directly to the API without /api.`
102
+ : "";
103
+ }
@@ -69,6 +69,8 @@ export interface DockerComposeProfileV1 {
69
69
  bindAddress: string;
70
70
  port: number;
71
71
  webSocketPaths: string[];
72
+ /** Path prefixes under /api/ that the gateway sends to the API with /api removed. */
73
+ apiPaths: string[];
72
74
  trustedProxyCidrs: string[];
73
75
  gatewayImage: string;
74
76
  };
@@ -107,4 +109,6 @@ export interface ComposeProfileSummary {
107
109
  profile: DockerComposeProfileV1;
108
110
  }
109
111
  export declare function initializeComposeProfile(projectRoot: string, name: string, options: InitializeComposeProfileOptions): ComposeProfileSummary;
112
+ /** The gateway runs only when some path must bypass the web service. */
113
+ export declare function composeHasGateway(profile: DockerComposeProfileV1): boolean;
110
114
  export declare function composeProfileDigest(profile: DockerComposeProfileV1): string;
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseComposeProfile = parseComposeProfile;
4
4
  exports.buildDefaultComposeProfile = buildDefaultComposeProfile;
5
5
  exports.initializeComposeProfile = initializeComposeProfile;
6
+ exports.composeHasGateway = composeHasGateway;
6
7
  exports.composeProfileDigest = composeProfileDigest;
7
8
  const node_crypto_1 = require("node:crypto");
8
9
  const node_fs_1 = require("node:fs");
9
10
  const node_net_1 = require("node:net");
10
11
  const lockfile_1 = require("./lockfile");
11
12
  const deploy_schema_1 = require("./deploy-schema");
13
+ const api_paths_1 = require("./api-paths");
12
14
  const websocket_paths_1 = require("./websocket-paths");
13
15
  /** Decimal core count, e.g. "0.25" or "2". */
14
16
  const CPU_VALUE = /^(?:0\.[0-9]{1,3}|[1-9][0-9]{0,2}(?:\.[0-9]{1,3})?)$/;
@@ -167,6 +169,7 @@ function parseComposeProfile(value) {
167
169
  "bindAddress",
168
170
  "port",
169
171
  "webSocketPaths",
172
+ "apiPaths",
170
173
  "trustedProxyCidrs",
171
174
  "gatewayImage",
172
175
  ], "exposure");
@@ -197,9 +200,10 @@ function parseComposeProfile(value) {
197
200
  if (port > 65535)
198
201
  throw new Error("Deployment exposure.port must be a TCP port.");
199
202
  const webSocketPaths = (0, websocket_paths_1.parseExactWebSocketPaths)(exposure.webSocketPaths ?? [], "exposure.webSocketPaths");
203
+ const apiPaths = (0, api_paths_1.parseApiPaths)(exposure.apiPaths ?? [], "exposure.apiPaths", webSocketPaths);
200
204
  const trustedProxyCidrs = parseTrustedProxyCidrs(exposure.trustedProxyCidrs ?? [], "exposure.trustedProxyCidrs");
201
- if (trustedProxyCidrs.length > 0 && webSocketPaths.length === 0) {
202
- throw new Error("Deployment exposure.trustedProxyCidrs requires at least one exposure.webSocketPaths entry.");
205
+ if (trustedProxyCidrs.length > 0 && webSocketPaths.length === 0 && apiPaths.length === 0) {
206
+ throw new Error("Deployment exposure.trustedProxyCidrs requires at least one exposure.webSocketPaths or exposure.apiPaths entry.");
203
207
  }
204
208
  const gatewayImage = exposure.gatewayImage === undefined
205
209
  ? "caddy:2.10-alpine"
@@ -238,6 +242,7 @@ function parseComposeProfile(value) {
238
242
  bindAddress,
239
243
  port,
240
244
  webSocketPaths,
245
+ apiPaths,
241
246
  trustedProxyCidrs,
242
247
  gatewayImage,
243
248
  },
@@ -302,6 +307,7 @@ function buildDefaultComposeProfile(projectName, options) {
302
307
  bindAddress: "127.0.0.1",
303
308
  port: 8080,
304
309
  webSocketPaths: [],
310
+ apiPaths: [],
305
311
  trustedProxyCidrs: [],
306
312
  gatewayImage: "caddy:2.10-alpine",
307
313
  },
@@ -385,6 +391,7 @@ function initializeComposeProfile(projectRoot, name, options) {
385
391
  || providers.events === "memory"
386
392
  || providers.jobs === "local";
387
393
  draft.workloads.api.replicas = usesEmbeddedProviders ? 1 : 2;
394
+ draft.exposure.apiPaths = (0, api_paths_1.installedApiPaths)(manifest.modules);
388
395
  draft.dependencies.postgres.mode = providers.database === "postgres" ? "managed" : "disabled";
389
396
  draft.dependencies.redis.mode = usesRedis ? "managed" : "disabled";
390
397
  draft.dependencies.objectStorage.mode = providers["object-storage"] === "s3"
@@ -423,6 +430,10 @@ function initializeComposeProfile(projectRoot, name, options) {
423
430
  (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(validated, null, 2)}\n`, { flag: "wx" });
424
431
  return { name, path, profile: validated };
425
432
  }
433
+ /** The gateway runs only when some path must bypass the web service. */
434
+ function composeHasGateway(profile) {
435
+ return profile.exposure.webSocketPaths.length > 0 || profile.exposure.apiPaths.length > 0;
436
+ }
426
437
  function composeProfileDigest(profile) {
427
438
  return `sha256:${(0, node_crypto_1.createHash)("sha256").update(JSON.stringify(profile)).digest("hex")}`;
428
439
  }
@@ -1,4 +1,4 @@
1
- import type { DockerComposeProfileV1 } from "./deploy-compose-profile";
1
+ import { type DockerComposeProfileV1 } from "./deploy-compose-profile";
2
2
  /**
3
3
  * Compose project rendering.
4
4
  *
@@ -10,6 +10,8 @@ exports.renderComposeDeployment = renderComposeDeployment;
10
10
  const node_crypto_1 = require("node:crypto");
11
11
  const node_fs_1 = require("node:fs");
12
12
  const node_path_1 = require("node:path");
13
+ const api_paths_1 = require("./api-paths");
14
+ const deploy_compose_profile_1 = require("./deploy-compose-profile");
13
15
  const deploy_schema_1 = require("./deploy-schema");
14
16
  const lockfile_1 = require("./lockfile");
15
17
  const toolchain_1 = require("./toolchain");
@@ -249,12 +251,26 @@ function gatewayService(profile, image, release, stateDigest) {
249
251
  }
250
252
  `
251
253
  : "";
254
+ const api = `${serviceName(profile, "api")}:${CONTAINER_PORT}`;
255
+ const webSocketRoutes = profile.exposure.webSocketPaths.length
256
+ ? ` @api_websockets path ${profile.exposure.webSocketPaths.join(" ")}\n` +
257
+ ` handle @api_websockets {\n` +
258
+ ` reverse_proxy ${api}\n` +
259
+ ` }\n`
260
+ : "";
261
+ const apiPathRoutes = profile.exposure.apiPaths.length
262
+ ? ` @api_paths path ${profile.exposure.apiPaths
263
+ .map((path) => `${(0, api_paths_1.apiPathBase)(path)} ${(0, api_paths_1.apiPathBase)(path)}/*`)
264
+ .join(" ")}\n` +
265
+ ` handle @api_paths {\n` +
266
+ ` uri strip_prefix ${api_paths_1.API_PATH_PREFIX}\n` +
267
+ ` reverse_proxy ${api}\n` +
268
+ ` }\n`
269
+ : "";
252
270
  const caddyfile = globalOptions +
253
271
  `:3000 {\n` +
254
- ` @api_websockets path ${profile.exposure.webSocketPaths.join(" ")}\n` +
255
- ` handle @api_websockets {\n` +
256
- ` reverse_proxy ${serviceName(profile, "api")}:${CONTAINER_PORT}\n` +
257
- ` }\n` +
272
+ webSocketRoutes +
273
+ apiPathRoutes +
258
274
  ` handle {\n` +
259
275
  ` reverse_proxy ${serviceName(profile, "web")}:${CONTAINER_PORT}\n` +
260
276
  ` }\n` +
@@ -278,7 +294,7 @@ function gatewayService(profile, image, release, stateDigest) {
278
294
  }
279
295
  function renderComposeDocument(profile, release, images, stateDigest, workerCommand = ["bun", "dist/main-worker.js"]) {
280
296
  const services = [];
281
- const hasGateway = profile.exposure.webSocketPaths.length > 0;
297
+ const hasGateway = (0, deploy_compose_profile_1.composeHasGateway)(profile);
282
298
  if (profile.dependencies.postgres.mode === "managed" && images.postgres) {
283
299
  services.push(postgresService(profile, images.postgres));
284
300
  }
@@ -300,7 +316,7 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
300
316
  }));
301
317
  if (hasGateway) {
302
318
  if (!images.gateway) {
303
- throw new Error("A resolved gateway image is required when WebSocket paths are configured.");
319
+ throw new Error("A resolved gateway image is required when WebSocket or API paths are configured.");
304
320
  }
305
321
  services.push(gatewayService(profile, images.gateway, release, stateDigest));
306
322
  }
@@ -350,7 +366,7 @@ function defaultComposeImages(profile, release) {
350
366
  return {
351
367
  api: `${profile.release.apiRepository}:${release}`,
352
368
  web: `${profile.release.webRepository}:${release}`,
353
- gateway: profile.exposure.webSocketPaths.length > 0 ? profile.exposure.gatewayImage : null,
369
+ gateway: (0, deploy_compose_profile_1.composeHasGateway)(profile) ? profile.exposure.gatewayImage : null,
354
370
  postgres: profile.dependencies.postgres.mode === "managed"
355
371
  ? profile.dependencies.postgres.image
356
372
  : null,
@@ -11,6 +11,7 @@ exports.rollbackComposeDeployment = rollbackComposeDeployment;
11
11
  exports.composeRuntimeRoot = composeRuntimeRoot;
12
12
  const node_crypto_1 = require("node:crypto");
13
13
  const node_path_1 = require("node:path");
14
+ const api_paths_1 = require("./api-paths");
14
15
  const deploy_driver_1 = require("./deploy-driver");
15
16
  const deploy_compose_exec_1 = require("./deploy-compose-exec");
16
17
  const deploy_compose_sync_1 = require("./deploy-compose-sync");
@@ -373,7 +374,7 @@ function planComposeDeployment(projectRoot, profileName, release, runner = deplo
373
374
  actions.push({
374
375
  order: order++,
375
376
  kind: "application",
376
- description: `Roll out api, web${profile.workloads.worker ? ", worker" : ""}${profile.exposure.webSocketPaths.length ? ", gateway" : ""} and wait for health.`,
377
+ description: `Roll out api, web${profile.workloads.worker ? ", worker" : ""}${(0, deploy_compose_profile_1.composeHasGateway)(profile) ? ", gateway" : ""} and wait for health.${(0, api_paths_1.directApiRoutes)(profile.exposure.apiPaths)}`,
377
378
  });
378
379
  actions.push({
379
380
  order: order++,
@@ -50,6 +50,8 @@ export interface DeployProfileV1 {
50
50
  ingressClassName: string;
51
51
  tlsSecretName: string | null;
52
52
  webSocketPaths: string[];
53
+ /** Path prefixes under /api/ that the Ingress sends to the API with /api removed. */
54
+ apiPaths: string[];
53
55
  };
54
56
  workloads: {
55
57
  api: WorkloadProfile;
@@ -78,6 +80,7 @@ export interface DeploymentProfileSummary {
78
80
  path: string;
79
81
  profile: DeployProfileV1;
80
82
  }
83
+ export declare function parseDeploymentProfile(value: unknown): DeployProfileV1;
81
84
  export declare function loadDeploymentProfile(projectRoot: string, name: string): DeployProfileV1;
82
85
  export declare function listDeploymentProfiles(projectRoot: string): DeploymentProfileSummary[];
83
86
  export interface InitializeDeploymentProfileOptions {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.profilePath = exports.profileDirectory = void 0;
4
+ exports.parseDeploymentProfile = parseDeploymentProfile;
4
5
  exports.loadDeploymentProfile = loadDeploymentProfile;
5
6
  exports.listDeploymentProfiles = listDeploymentProfiles;
6
7
  exports.initializeDeploymentProfile = initializeDeploymentProfile;
@@ -11,6 +12,7 @@ const node_fs_1 = require("node:fs");
11
12
  const node_path_1 = require("node:path");
12
13
  const lockfile_1 = require("./lockfile");
13
14
  const deploy_schema_1 = require("./deploy-schema");
15
+ const api_paths_1 = require("./api-paths");
14
16
  const websocket_paths_1 = require("./websocket-paths");
15
17
  var deploy_schema_2 = require("./deploy-schema");
16
18
  Object.defineProperty(exports, "profileDirectory", { enumerable: true, get: function () { return deploy_schema_2.profileDirectory; } });
@@ -84,7 +86,7 @@ function parseStatefulDependency(value, field, extraKeys = []) {
84
86
  function parseRedisDependency(value, field) {
85
87
  return parseStatefulDependency(value, field);
86
88
  }
87
- function parseProfile(value) {
89
+ function parseDeploymentProfile(value) {
88
90
  if (!(0, deploy_schema_1.isRecord)(value))
89
91
  throw new Error("Deployment profile must be a JSON object.");
90
92
  (0, deploy_schema_1.assertOnlyKeys)(value, [
@@ -117,7 +119,7 @@ function parseProfile(value) {
117
119
  const runtimeConfig = (0, deploy_schema_1.requiredRecord)(value, "runtimeConfig");
118
120
  (0, deploy_schema_1.assertOnlyKeys)(target, ["context", "clusterFingerprint", "namespace", "release", "createNamespace"], "target");
119
121
  (0, deploy_schema_1.assertOnlyKeys)(release, ["strategy", "tagPattern", "apiRepository", "webRepository"], "release");
120
- (0, deploy_schema_1.assertOnlyKeys)(exposure, ["mode", "host", "ingressClassName", "tlsSecretName", "webSocketPaths"], "exposure");
122
+ (0, deploy_schema_1.assertOnlyKeys)(exposure, ["mode", "host", "ingressClassName", "tlsSecretName", "webSocketPaths", "apiPaths"], "exposure");
121
123
  (0, deploy_schema_1.assertOnlyKeys)(workloads, ["api", "web", "worker"], "workloads");
122
124
  (0, deploy_schema_1.assertOnlyKeys)(dependencies, ["postgres", "redis", "objectStorage"], "dependencies");
123
125
  (0, deploy_schema_1.assertOnlyKeys)(secrets, ["api", "web", "imagePull"], "secrets");
@@ -167,6 +169,10 @@ function parseProfile(value) {
167
169
  if (exposureMode === "nodePort" && webSocketPaths.length > 0) {
168
170
  throw new Error("Deployment exposure.webSocketPaths requires exposure.mode ingress.");
169
171
  }
172
+ const apiPaths = (0, api_paths_1.parseApiPaths)(exposure.apiPaths ?? [], "exposure.apiPaths", webSocketPaths);
173
+ if (exposureMode === "nodePort" && apiPaths.length > 0) {
174
+ throw new Error("Deployment exposure.apiPaths requires exposure.mode ingress.");
175
+ }
170
176
  const postgres = parseStatefulDependency((0, deploy_schema_1.requiredRecord)(dependencies, "postgres"), "dependencies.postgres");
171
177
  const redis = parseRedisDependency((0, deploy_schema_1.requiredRecord)(dependencies, "redis"), "dependencies.redis");
172
178
  const objectStorageRecord = (0, deploy_schema_1.requiredRecord)(dependencies, "objectStorage");
@@ -210,6 +216,7 @@ function parseProfile(value) {
210
216
  ingressClassName,
211
217
  tlsSecretName,
212
218
  webSocketPaths,
219
+ apiPaths,
213
220
  },
214
221
  workloads: {
215
222
  api: parseWorkload((0, deploy_schema_1.requiredRecord)(workloads, "api"), "workloads.api"),
@@ -246,7 +253,7 @@ function loadDeploymentProfile(projectRoot, name) {
246
253
  catch (error) {
247
254
  throw new Error(`Cannot read deployment profile "${name}": ${error.message}`);
248
255
  }
249
- return parseProfile(parsed);
256
+ return parseDeploymentProfile(parsed);
250
257
  }
251
258
  function listDeploymentProfiles(projectRoot) {
252
259
  const directory = (0, deploy_schema_1.profileDirectory)(projectRoot);
@@ -319,6 +326,7 @@ function initializeDeploymentProfile(projectRoot, name, options) {
319
326
  ingressClassName: "traefik",
320
327
  tlsSecretName: null,
321
328
  webSocketPaths: [],
329
+ apiPaths: (0, api_paths_1.installedApiPaths)(manifest.modules),
322
330
  },
323
331
  workloads: {
324
332
  api: {
@@ -418,7 +426,7 @@ function initializeDeploymentProfile(projectRoot, name, options) {
418
426
  ],
419
427
  },
420
428
  };
421
- const validated = parseProfile(defaultProfile);
429
+ const validated = parseDeploymentProfile(defaultProfile);
422
430
  (0, node_fs_1.mkdirSync)((0, deploy_schema_1.profileDirectory)(projectRoot), { recursive: true });
423
431
  (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(validated, null, 2)}\n`, { flag: "wx" });
424
432
  return { name, path, profile: validated };
@@ -5,6 +5,7 @@ const node_fs_1 = require("node:fs");
5
5
  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
+ const api_paths_1 = require("./api-paths");
8
9
  const lockfile_1 = require("./lockfile");
9
10
  const toolchain_1 = require("./toolchain");
10
11
  function quote(value) {
@@ -525,6 +526,57 @@ spec:
525
526
  app.kubernetes.io/name: ${name}
526
527
  `;
527
528
  }
529
+ function ingressPath(path, pathType, service) {
530
+ return ` - path: ${quote(path)}
531
+ pathType: ${pathType}
532
+ backend:
533
+ service:
534
+ name: ${service}
535
+ port: { number: 3000 }
536
+ `;
537
+ }
538
+ /**
539
+ * Direct API paths need /api removed on the way to the API, which a standard
540
+ * Ingress cannot express, so they get their own Ingress with a Traefik
541
+ * stripPrefix middleware. ImplementationSpecific makes Traefik match the
542
+ * trailing-slash prefix as a plain PathPrefix, and the Exact path adds the bare
543
+ * path, so every controller version routes the same set of requests.
544
+ */
545
+ function apiPathIngress(profile, tls) {
546
+ if (profile.exposure.apiPaths.length === 0)
547
+ return "";
548
+ const apiService = `${profile.target.release}-api`;
549
+ const middleware = `${profile.target.release}-strip-api-prefix`;
550
+ const paths = profile.exposure.apiPaths
551
+ .map((path) => {
552
+ const base = (0, api_paths_1.apiPathBase)(path);
553
+ return (ingressPath(base, "Exact", apiService) +
554
+ ingressPath(`${base}/`, "ImplementationSpecific", apiService));
555
+ })
556
+ .join("");
557
+ return `---
558
+ apiVersion: traefik.io/v1alpha1
559
+ kind: Middleware
560
+ metadata:
561
+ name: ${middleware}
562
+ spec:
563
+ stripPrefix:
564
+ prefixes: [${quote(api_paths_1.API_PATH_PREFIX)}]
565
+ ---
566
+ apiVersion: networking.k8s.io/v1
567
+ kind: Ingress
568
+ metadata:
569
+ name: ${profile.target.release}-api-paths
570
+ annotations:
571
+ traefik.ingress.kubernetes.io/router.middlewares: ${quote(`${profile.target.namespace}-${middleware}@kubernetescrd`)}
572
+ spec:
573
+ ingressClassName: ${quote(profile.exposure.ingressClassName)}
574
+ ${tls} rules:
575
+ - host: ${quote(profile.exposure.host)}
576
+ http:
577
+ paths:
578
+ ${paths}`;
579
+ }
528
580
  function exposure(profile) {
529
581
  const webService = `${profile.target.release}-web`;
530
582
  const apiService = `${profile.target.release}-api`;
@@ -548,13 +600,7 @@ spec:
548
600
  `
549
601
  : "";
550
602
  const webSocketPaths = profile.exposure.webSocketPaths
551
- .map((path) => ` - path: ${quote(path)}
552
- pathType: Exact
553
- backend:
554
- service:
555
- name: ${apiService}
556
- port: { number: 3000 }
557
- `)
603
+ .map((path) => ingressPath(path, "Exact", apiService))
558
604
  .join("");
559
605
  return `apiVersion: networking.k8s.io/v1
560
606
  kind: Ingress
@@ -572,7 +618,7 @@ ${webSocketPaths} - path: /
572
618
  service:
573
619
  name: ${webService}
574
620
  port: { number: 3000 }
575
- `;
621
+ ${apiPathIngress(profile, tls)}`;
576
622
  }
577
623
  function applicationManifest(profile, images, rolloutStateDigest, workerCommand) {
578
624
  const runtimeConfigDigest = derivedRuntimeConfigDigest(profile);
package/dist/deploy.js CHANGED
@@ -13,6 +13,7 @@ const node_crypto_1 = require("node:crypto");
13
13
  const node_fs_1 = require("node:fs");
14
14
  const node_os_1 = require("node:os");
15
15
  const node_path_1 = require("node:path");
16
+ const api_paths_1 = require("./api-paths");
16
17
  const deploy_profile_1 = require("./deploy-profile");
17
18
  const deploy_render_1 = require("./deploy-render");
18
19
  function defaultRunner(command, args, options) {
@@ -168,6 +169,25 @@ function doctorDeployment(projectRoot, profileName, runner = defaultRunner) {
168
169
  ? `IngressClass ${profile.exposure.ingressClassName} exists.`
169
170
  : `IngressClass ${profile.exposure.ingressClassName} is missing or inaccessible.`,
170
171
  });
172
+ if (profile.exposure.apiPaths.length > 0) {
173
+ // Direct API paths remove /api with a Traefik Middleware resource.
174
+ const middlewareCrd = runner("kubectl", [
175
+ "--context",
176
+ profile.target.context,
177
+ "get",
178
+ "crd",
179
+ "middlewares.traefik.io",
180
+ "-o",
181
+ "name",
182
+ ], { capture: true });
183
+ findings.push({
184
+ code: "crd:middlewares.traefik.io",
185
+ ok: middlewareCrd.status === 0,
186
+ message: middlewareCrd.status === 0
187
+ ? "Traefik Middleware resources are available for exposure.apiPaths."
188
+ : "exposure.apiPaths needs the Traefik Middleware CRD (middlewares.traefik.io); serve the IngressClass with Traefik or remove exposure.apiPaths.",
189
+ });
190
+ }
171
191
  if (profile.exposure.tlsSecretName) {
172
192
  const tlsType = runner("kubectl", kubectlArgs(profile, [
173
193
  "get",
@@ -465,7 +485,7 @@ function planDeployment(projectRoot, profileName, release, runner = defaultRunne
465
485
  {
466
486
  order: 3,
467
487
  kind: "application",
468
- description: `Roll out API, web${profile.workloads.worker ? ", and worker" : ""} workloads with zero unavailable replicas.`,
488
+ description: `Roll out API, web${profile.workloads.worker ? ", and worker" : ""} workloads with zero unavailable replicas.${(0, api_paths_1.directApiRoutes)(profile.exposure.apiPaths)}`,
469
489
  },
470
490
  {
471
491
  order: 4,