@podosoft/podokit 1.2.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 (187) hide show
  1. package/dist/add.d.ts +13 -2
  2. package/dist/add.js +135 -20
  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 +23 -2
  17. package/dist/lib.d.ts +4 -2
  18. package/dist/lib.js +5 -2
  19. package/dist/lockfile.d.ts +6 -1
  20. package/dist/lockfile.js +26 -6
  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/scripts/build.mjs +1 -0
  29. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  30. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  31. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  32. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  39. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  40. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  41. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  42. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  43. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  44. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  45. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  46. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  47. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  49. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  50. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  51. package/dist/templates/fullstack/apps/web/components.json +3 -1
  52. package/dist/templates/fullstack/apps/web/package.json +1 -1
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  71. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  72. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  88. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  89. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  90. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  91. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  93. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
  94. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  95. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  96. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  98. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  99. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  100. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  103. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  104. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  105. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  108. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  109. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  110. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  113. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  114. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  115. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  116. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  117. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  118. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  119. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  120. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  121. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  122. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  123. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  124. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  125. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  126. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  127. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  128. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  129. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  130. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  131. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  132. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  133. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  134. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  135. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  136. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  137. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  138. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  139. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  140. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  141. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  142. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  143. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  144. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  145. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  146. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  147. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  148. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  149. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  150. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  151. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  152. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  153. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  154. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  155. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  156. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  157. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  158. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  159. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  160. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  161. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  162. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  163. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  164. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  165. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  166. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  167. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  168. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  169. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  170. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  171. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  172. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  173. package/dist/templates/modules/redis/module.manifest.json +4 -0
  174. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  175. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  176. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  177. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
  178. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
  179. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  180. package/dist/templates/modules/sse/module.manifest.json +3 -1
  181. package/dist/templates/todo/AGENTS.md +9 -1
  182. package/dist/templates/todo/README.md +18 -5
  183. package/dist/update.d.ts +22 -0
  184. package/dist/update.js +68 -8
  185. package/package.json +2 -2
  186. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  187. 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
- import { type ProviderCapability } from "@podosoft/podokit-runtime";
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;
@@ -127,6 +136,8 @@ export declare function resolveModuleDir(name: string, modulesDir: string, proje
127
136
  /** Resolve a module and capture the package identity needed to replay external
128
137
  * modules during `podo update`. */
129
138
  export declare function resolveModule(name: string, modulesDir: string, projectRoot: string): ResolvedModule | null;
139
+ /** Name of the module that implements the selected provider for a capability. */
140
+ export declare function selectedProviderModule(modulesDir: string, projectRoot: string, providers: ProviderSelections, capability: ProviderCapability): string;
130
141
  /** List modules available to a project: the bundled ones under `modulesDir` plus,
131
142
  * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
132
143
  * in it. Bundled names take precedence on a clash. */
package/dist/add.js CHANGED
@@ -4,6 +4,7 @@ exports.SUPPORTED_MANIFEST_VERSION = void 0;
4
4
  exports.modulePackageOverlays = modulePackageOverlays;
5
5
  exports.resolveModuleDir = resolveModuleDir;
6
6
  exports.resolveModule = resolveModule;
7
+ exports.selectedProviderModule = selectedProviderModule;
7
8
  exports.listModules = listModules;
8
9
  exports.readModuleManifest = readModuleManifest;
9
10
  exports.addModule = addModule;
@@ -13,9 +14,11 @@ const node_module_1 = require("node:module");
13
14
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
14
15
  const podokit_runtime_1 = require("@podosoft/podokit-runtime");
15
16
  const lockfile_1 = require("./lockfile");
17
+ const api_paths_1 = require("./api-paths");
18
+ const module_api_paths_1 = require("./module-api-paths");
16
19
  /** Highest `module.manifest.json` schema version this CLI understands. A package
17
20
  * module declaring a higher version is rejected rather than mis-applied. */
18
- exports.SUPPORTED_MANIFEST_VERSION = 3;
21
+ exports.SUPPORTED_MANIFEST_VERSION = 4;
19
22
  /** Return the legacy targetApp package fields plus any additional app overlays. */
20
23
  function modulePackageOverlays(manifest) {
21
24
  const overlays = new Map();
@@ -100,17 +103,21 @@ function assertManifestVersion(name, manifest) {
100
103
  throw new Error(`Module "${name}" declares invalid provider ${capability}=${String(provider)}.`);
101
104
  }
102
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
+ }
103
112
  const maxApiReplicas = manifest.runtimeConstraints?.maxApiReplicas;
104
113
  if (maxApiReplicas !== undefined
105
114
  && (!Number.isSafeInteger(maxApiReplicas) || maxApiReplicas < 1)) {
106
115
  throw new Error(`Module "${name}" declares an invalid maxApiReplicas constraint.`);
107
116
  }
108
117
  }
109
- function providerModuleName(projectRoot, modulesDir, capability) {
110
- const project = (0, lockfile_1.readManifest)(projectRoot);
111
- if (!project)
112
- throw new Error("Provider capabilities require a PodoKit project manifest.");
113
- const selected = project.providers[capability];
118
+ /** Name of the module that implements the selected provider for a capability. */
119
+ function selectedProviderModule(modulesDir, projectRoot, providers, capability) {
120
+ const selected = providers[capability];
114
121
  for (const available of listModules(modulesDir, projectRoot)) {
115
122
  const resolved = resolveModule(available.name, modulesDir, projectRoot);
116
123
  if (!resolved)
@@ -122,6 +129,12 @@ function providerModuleName(projectRoot, modulesDir, capability) {
122
129
  throw new Error(`No module provides the selected ${capability} provider "${selected}". ` +
123
130
  `Choose another provider with podo provider set ${capability} <provider> --apply.`);
124
131
  }
132
+ function providerModuleName(projectRoot, modulesDir, capability) {
133
+ const project = (0, lockfile_1.readManifest)(projectRoot);
134
+ if (!project)
135
+ throw new Error("Provider capabilities require a PodoKit project manifest.");
136
+ return selectedProviderModule(modulesDir, projectRoot, project.providers, capability);
137
+ }
125
138
  function requiredModules(projectRoot, modulesDir, manifest) {
126
139
  const capabilities = (manifest.requiresCapabilities ?? []).map((capability) => providerModuleName(projectRoot, modulesDir, capability));
127
140
  return [...new Set([...(manifest.requires ?? []), ...capabilities])];
@@ -217,14 +230,24 @@ function projectName(projectRoot) {
217
230
  const pkg = readJson((0, node_path_1.join)(projectRoot, "package.json"));
218
231
  return typeof pkg.name === "string" ? pkg.name : "app";
219
232
  }
220
- function appendEnv(projectRoot, lines) {
233
+ function appendEnv(projectRoot, lines, anchors = []) {
221
234
  const file = (0, node_path_1.join)(projectRoot, ".env.example");
222
235
  if (!(0, node_fs_1.existsSync)(file))
223
236
  return;
224
237
  const current = (0, node_fs_1.readFileSync)(file, "utf8");
225
- const missing = lines.filter((line) => !current.split("\n").includes(line));
238
+ const existing = current.split("\n");
239
+ const missing = lines.filter((line) => !existing.includes(line));
226
240
  if (missing.length === 0)
227
241
  return;
242
+ // Each module appends its own blank-line separated block. Keep that shape
243
+ // when the block belongs in front of a displaced module's block.
244
+ const wanted = new Set(anchors);
245
+ const index = existing.findIndex((line, position) => position > 0 && existing[position - 1] === "" && wanted.has(line));
246
+ if (index !== -1) {
247
+ existing.splice(index, 0, ...missing, "");
248
+ (0, node_fs_1.writeFileSync)(file, existing.join("\n"));
249
+ return;
250
+ }
228
251
  const separator = current.endsWith("\n") ? "" : "\n";
229
252
  (0, node_fs_1.writeFileSync)(file, `${current}${separator}\n${missing.join("\n")}\n`);
230
253
  }
@@ -246,6 +269,91 @@ function isApplied(projectRoot, modulesDir, module) {
246
269
  }
247
270
  return false;
248
271
  }
272
+ function injectionKey(file, marker) {
273
+ return `${file}\n${marker}`;
274
+ }
275
+ /** Modules `applyModule` would newly apply, in application order. */
276
+ function plannedModules(projectRoot, module, modulesDir, planned = new Set(), order = []) {
277
+ planned.add(module);
278
+ const resolved = resolveModule(module, modulesDir, projectRoot);
279
+ if (!resolved)
280
+ return order;
281
+ for (const required of requiredModules(projectRoot, modulesDir, readModuleManifest(resolved.dir))) {
282
+ if (planned.has(required) || isApplied(projectRoot, modulesDir, required))
283
+ continue;
284
+ plannedModules(projectRoot, required, modulesDir, planned, order);
285
+ }
286
+ order.push(module);
287
+ return order;
288
+ }
289
+ function modulePlacement(projectRoot, module, modulesDir) {
290
+ const project = (0, lockfile_1.readManifest)(projectRoot);
291
+ if (!project)
292
+ return undefined;
293
+ const installed = [...project.modules].sort((a, b) => a.order - b.order);
294
+ const installedNames = new Set(installed.map((entry) => entry.name));
295
+ const fresh = new Set(plannedModules(projectRoot, module, modulesDir).filter((name) => !installedNames.has(name)));
296
+ if (!fresh.size)
297
+ return undefined;
298
+ const provided = new Set();
299
+ for (const name of fresh) {
300
+ const resolved = resolveModule(name, modulesDir, projectRoot);
301
+ if (!resolved)
302
+ continue;
303
+ for (const capability of Object.keys(readModuleManifest(resolved.dir).provides ?? {})) {
304
+ provided.add(capability);
305
+ }
306
+ }
307
+ const vars = {
308
+ projectName: projectName(projectRoot),
309
+ ...(0, lockfile_1.manifestTemplateVars)(project),
310
+ };
311
+ const manifests = installed.map((entry) => {
312
+ const resolved = resolveModule(entry.packageName ?? entry.name, modulesDir, projectRoot);
313
+ return resolved ? readModuleManifest(resolved.dir, vars) : undefined;
314
+ });
315
+ const first = manifests.findIndex((manifest) => manifest !== undefined && ((manifest.requires ?? []).some((name) => fresh.has(name))
316
+ || (manifest.requiresCapabilities ?? []).some((capability) => provided.has(capability))));
317
+ if (first === -1)
318
+ return undefined;
319
+ const injections = new Map();
320
+ const env = [];
321
+ for (const manifest of manifests.slice(first)) {
322
+ if (!manifest)
323
+ continue;
324
+ for (const injection of manifest.inject ?? []) {
325
+ const key = injectionKey(injection.file, injection.marker);
326
+ injections.set(key, [...(injections.get(key) ?? []), injection.text]);
327
+ }
328
+ env.push(...(manifest.env ?? []));
329
+ }
330
+ return { before: installed[first].name, injections, env };
331
+ }
332
+ /** Insert wiring at its marker, or in front of the first displaced module's wiring. */
333
+ function injectAt(content, injection, anchors = []) {
334
+ if (!anchors.length || content.includes(injection.text)) {
335
+ return (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text);
336
+ }
337
+ const lines = content.split("\n");
338
+ const end = lines.findIndex((line) => line.includes(injection.marker));
339
+ if (end === -1)
340
+ throw new Error(`Marker not found: ${injection.marker}`);
341
+ const beginMarker = injection.marker.replace("podokit:end:", "podokit:begin:");
342
+ const begin = beginMarker === injection.marker
343
+ ? -1
344
+ : lines.slice(0, end).findIndex((line) => line.includes(beginMarker));
345
+ const wanted = new Set(anchors.map((text) => text.split("\n")[0].trim()));
346
+ let index = end;
347
+ for (let line = begin + 1; line < end; line += 1) {
348
+ if (wanted.has(lines[line].trim())) {
349
+ index = line;
350
+ break;
351
+ }
352
+ }
353
+ const indent = lines[end].match(/^\s*/)?.[0] ?? "";
354
+ lines.splice(index, 0, `${indent}${injection.text}`);
355
+ return lines.join("\n");
356
+ }
249
357
  /**
250
358
  * Apply a module to an existing generated project: overlay files, merge the
251
359
  * target app's package.json dependencies and scripts, append env example lines,
@@ -262,16 +370,21 @@ function addModule(options) {
262
370
  })
263
371
  .map(([path]) => path)
264
372
  : [];
265
- const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false);
266
- if (options.adopt && result.adopted.length) {
267
- releaseOwnedPaths(options.projectRoot, result.adopted);
373
+ const placement = modulePlacement(options.projectRoot, options.module, options.modulesDir);
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);
268
377
  }
269
378
  // Record the module (and any auto-added requirements), fold in the modules'
270
379
  // declared ownedGlobs, and refresh the lock.
271
- const records = [...result.added, result.module]
380
+ const records = [...applied.added, applied.module]
272
381
  .map((name) => resolveModule(name, options.modulesDir, options.projectRoot))
273
382
  .filter((module) => module !== null)
274
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
+ };
275
388
  (0, lockfile_1.recordModules)(options.projectRoot, records, options.podokitVersion, result.ownedGlobs, previousLock
276
389
  ? {
277
390
  previous: previousLock,
@@ -279,10 +392,10 @@ function addModule(options) {
279
392
  modulePaths: [...new Set(result.touched)],
280
393
  adoptedPaths: result.adopted,
281
394
  }
282
- : undefined, result.managedOverrides);
395
+ : undefined, result.managedOverrides, placement?.before);
283
396
  return result;
284
397
  }
285
- function applyModule(projectRoot, module, modulesDir, applied, adopt) {
398
+ function applyModule(projectRoot, module, modulesDir, applied, adopt, placement) {
286
399
  const resolved = resolveModule(module, modulesDir, projectRoot);
287
400
  if (!resolved) {
288
401
  const available = listModules(modulesDir, projectRoot).map((m) => m.name);
@@ -318,8 +431,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
318
431
  for (const required of requiredModules(projectRoot, modulesDir, manifest)) {
319
432
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
320
433
  continue;
321
- const result = applyModule(projectRoot, required, modulesDir, applied, adopt);
322
- added.push(required, ...result.added);
434
+ const result = applyModule(projectRoot, required, modulesDir, applied, adopt, placement);
435
+ added.push(...result.added, required);
323
436
  ownedGlobs.push(...result.ownedGlobs);
324
437
  managedOverrides.push(...result.managedOverrides);
325
438
  preserved.push(...result.preserved);
@@ -356,7 +469,7 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
356
469
  }
357
470
  // 3) append env example lines
358
471
  if (manifest.env?.length) {
359
- appendEnv(projectRoot, manifest.env);
472
+ appendEnv(projectRoot, manifest.env, placement?.env);
360
473
  touched.push(".env.example");
361
474
  }
362
475
  // 4) inject wiring at markers
@@ -370,7 +483,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
370
483
  const content = (0, node_fs_1.readFileSync)(target, "utf8");
371
484
  if (injection.optional && !content.includes(injection.marker))
372
485
  continue;
373
- (0, node_fs_1.writeFileSync)(target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
486
+ const anchors = placement?.injections.get(injectionKey(injection.file, injection.marker));
487
+ (0, node_fs_1.writeFileSync)(target, injectAt(content, injection, anchors));
374
488
  touched.push(injection.file);
375
489
  }
376
490
  const instructions = (manifest.instructions ?? []).map((line) => line.replace(/<app>/g, appName));
@@ -386,12 +500,13 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
386
500
  };
387
501
  }
388
502
  function moduleRecord(module) {
389
- const migrations = readModuleManifest(module.dir).migrations ?? [];
503
+ const manifest = readModuleManifest(module.dir);
390
504
  return {
391
505
  name: module.name,
392
506
  packageName: module.packageName,
393
507
  moduleVersion: module.moduleVersion,
394
- appliedMigrations: migrations.map((migration) => migration.id),
508
+ appliedMigrations: (manifest.migrations ?? []).map((migration) => migration.id),
509
+ ...(manifest.apiPaths?.length ? { appliedApiPaths: manifest.apiPaths } : {}),
395
510
  };
396
511
  }
397
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 {