@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
@@ -4,7 +4,12 @@ import { MemoryEventBus } from "@podosoft/podokit-runtime";
4
4
  import { Elysia } from "elysia";
5
5
  import type { RequestIdentity } from "../core/services";
6
6
  import { ReadinessService } from "../health/readiness.service";
7
- import { eventsRoutes, requestEventIdentity, type EventIdentity } from "./events.module";
7
+ import {
8
+ eventsRoutes,
9
+ maxStreamsPerUser,
10
+ requestEventIdentity,
11
+ type EventIdentity,
12
+ } from "./events.module";
8
13
  import { EventsService } from "./events.service";
9
14
 
10
15
  /** Maps the `x-user`, `x-admin`, `x-roles`, and `x-org` test headers to the request identity. */
@@ -201,3 +206,45 @@ describe("default event identity", () => {
201
206
  expect(await identity.subscriber(new Request("http://localhost/events/stream"))).toBeUndefined();
202
207
  });
203
208
  });
209
+
210
+ describe("stream limit configuration", () => {
211
+ it("reads SSE_MAX_STREAMS_PER_USER and defaults to eight", () => {
212
+ expect(maxStreamsPerUser({})).toBe(8);
213
+ expect(maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: "3" })).toBe(3);
214
+ for (const value of ["0", "-1", "1.5", "many"]) {
215
+ expect(() => maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: value })).toThrow(
216
+ "SSE_MAX_STREAMS_PER_USER must be a positive integer",
217
+ );
218
+ }
219
+ });
220
+
221
+ it("answers 429 EVENTS_STREAM_LIMIT over HTTP", async () => {
222
+ const events = new EventsService(new MemoryEventBus(), new ReadinessService(), {
223
+ maxStreamsPerUser: 1,
224
+ });
225
+ await events.connect();
226
+ const controllers: AbortController[] = [];
227
+ opened.push({ events, controllers });
228
+ const app = new Elysia()
229
+ .onError(({ error, set }) => {
230
+ if (!(error instanceof AppException)) return undefined;
231
+ set.status = error.statusCode;
232
+ return { code: error.code };
233
+ })
234
+ .use(eventsRoutes(events, headerIdentity));
235
+ const send = (): Promise<Response> => {
236
+ const controller = new AbortController();
237
+ controllers.push(controller);
238
+ return app.handle(
239
+ new Request("http://localhost/events/stream", {
240
+ headers: { "x-user": "u1" },
241
+ signal: controller.signal,
242
+ }),
243
+ );
244
+ };
245
+ expect((await send()).status).toBe(200);
246
+ const refused = await send();
247
+ expect(refused.status).toBe(429);
248
+ expect(await refused.json()).toEqual({ code: "EVENTS_STREAM_LIMIT" });
249
+ });
250
+ });
@@ -22,6 +22,15 @@ import { EventsService } from "./events.service";
22
22
 
23
23
  export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
24
24
 
25
+ /** `SSE_MAX_STREAMS_PER_USER`: open streams one user may hold on each API replica. */
26
+ export function maxStreamsPerUser(env: Record<string, string | undefined> = process.env): number {
27
+ const value = Number(env.SSE_MAX_STREAMS_PER_USER ?? 8);
28
+ if (!Number.isSafeInteger(value) || value < 1) {
29
+ throw new Error("SSE_MAX_STREAMS_PER_USER must be a positive integer");
30
+ }
31
+ return value;
32
+ }
33
+
25
34
  /** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
26
35
  export interface EventIdentity {
27
36
  subscriber(request: Request): Promise<EventSubscriber | undefined>;
@@ -84,7 +93,9 @@ export function eventsRoutes(
84
93
  "Signed-in users only. `topics` is a comma-separated list; without it the stream " +
85
94
  "carries every topic that has no authorizer. Each data event is a default " +
86
95
  "`message` whose data is `{ topic, data }`. A `resync` event means events were " +
87
- "dropped and the client should reload its state.",
96
+ "dropped and the client should reload its state. A user may hold " +
97
+ "`SSE_MAX_STREAMS_PER_USER` streams (default 8) on each API replica; another " +
98
+ "answers 429 `EVENTS_STREAM_LIMIT`.",
88
99
  },
89
100
  })
90
101
  .post("/events", async ({ request, body, set }) => {
@@ -132,7 +143,9 @@ const eventsPlugin: AppPlugin = ({ services }) =>
132
143
  export const eventsModule: PodokitModule = {
133
144
  name: "sse",
134
145
  configure: (_env, services): void => {
135
- const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS));
146
+ const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS), {
147
+ maxStreamsPerUser: maxStreamsPerUser(),
148
+ });
136
149
  services.register(SSE_EVENTS, events, () => events.close());
137
150
  services.onStart(() => events.connect());
138
151
  },
@@ -38,6 +38,22 @@ function user(userId: string, roles: string[] = [], organizationId?: string): Ev
38
38
  return { userId, roles, ...(organizationId ? { organizationId } : {}) };
39
39
  }
40
40
 
41
+ /** A memory bus that can report a transport interruption, as the Redis bus does after reconnecting. */
42
+ class InterruptibleBus extends MemoryEventBus {
43
+ readonly listeners = new Set<() => void>();
44
+
45
+ onInterruption(listener: () => void): () => void {
46
+ this.listeners.add(listener);
47
+ return () => {
48
+ this.listeners.delete(listener);
49
+ };
50
+ }
51
+
52
+ interrupt(): void {
53
+ for (const listener of this.listeners) listener();
54
+ }
55
+ }
56
+
41
57
  const services: EventsService[] = [];
42
58
 
43
59
  async function service(
@@ -268,6 +284,23 @@ describe("per-connection safety", () => {
268
284
  expect(decoded(await stream.next())).toEqual({ topic: "t", data: 6 });
269
285
  });
270
286
 
287
+ it("tells every open stream to resync after a transport interruption", async () => {
288
+ const bus = new InterruptibleBus();
289
+ const events = await service({}, bus);
290
+ const topical = await open(events, user("a"), ["t"]);
291
+ const everything = await open(events, user("b"));
292
+ await events.publishAsync({ topic: "t", data: 1 });
293
+
294
+ bus.interrupt();
295
+ expect(await topical.next()).toEqual({ kind: "resync" });
296
+ expect(await everything.next()).toEqual({ kind: "resync" });
297
+ await events.publishAsync({ topic: "t", data: 2 });
298
+ expect(decoded(await topical.next())).toEqual({ topic: "t", data: 2 });
299
+
300
+ await events.close();
301
+ expect(bus.listeners.size).toBe(0);
302
+ });
303
+
271
304
  it("reuses one heartbeat timer per connection and releases it on disconnect", async () => {
272
305
  const scheduler = fakeScheduler();
273
306
  const events = await service({ scheduler });
@@ -353,6 +386,41 @@ describe("per-connection safety", () => {
353
386
  });
354
387
  });
355
388
 
389
+ describe("per-user stream limit", () => {
390
+ it("refuses a user's stream beyond the limit on this replica and frees slots on close", async () => {
391
+ const events = await service({ maxStreamsPerUser: 2 });
392
+ const first = await open(events, user("a"));
393
+ await open(events, user("a"));
394
+
395
+ const refused = await open(events, user("a")).catch((error: unknown) => error);
396
+ expect(refused).toBeInstanceOf(AppException);
397
+ expect((refused as AppException).code).toBe("EVENTS_STREAM_LIMIT");
398
+ expect((refused as AppException).statusCode).toBe(429);
399
+ expect(events.streamCount).toBe(2);
400
+
401
+ // Other users keep their own allowance.
402
+ await open(events, user("b"));
403
+ first.close();
404
+ await open(events, user("a"));
405
+ expect(events.streamCount).toBe(3);
406
+ });
407
+
408
+ it("counts only live streams after a client disconnects", async () => {
409
+ const events = await service({ maxStreamsPerUser: 1 });
410
+ const controller = new AbortController();
411
+ await events.open(user("a"), undefined, controller.signal);
412
+ controller.abort();
413
+ await open(events, user("a"));
414
+ expect(events.streamCount).toBe(1);
415
+ });
416
+
417
+ it("allows eight streams per user by default", async () => {
418
+ const events = await service();
419
+ for (let index = 0; index < 8; index += 1) await open(events, user("a"));
420
+ await expect(open(events, user("a"))).rejects.toThrow("Too many open event streams");
421
+ });
422
+ });
423
+
356
424
  describe("SSE wire format", () => {
357
425
  it("encodes data events as default messages and control frames separately", () => {
358
426
  expect(encodeFrame({ kind: "event", id: "7", data: '{"topic":"t","data":1}' })).toBe(
@@ -27,6 +27,8 @@ export interface EventsServiceOptions {
27
27
  retryMs?: number;
28
28
  /** Streams end after this long so clients reconnect with a fresh identity. */
29
29
  maxConnectionMs?: number;
30
+ /** Open streams one user may hold on this replica. */
31
+ maxStreamsPerUser?: number;
30
32
  scheduler?: Scheduler;
31
33
  now?: () => number;
32
34
  }
@@ -35,11 +37,14 @@ export class EventsService {
35
37
  readonly topics = new TopicAuthorizers();
36
38
  readonly retryMs: number;
37
39
  private readonly streamOptions: EventStreamOptions;
40
+ private readonly maxStreamsPerUser: number;
38
41
  private readonly streams = new Set<EventStream>();
42
+ private readonly byUser = new Map<string, Set<EventStream>>();
39
43
  private readonly byTopic = new Map<string, Set<EventStream>>();
40
44
  private readonly everyTopic = new Set<EventStream>();
41
45
  private unregisterReadiness?: () => void;
42
46
  private unsubscribe?: Unsubscribe;
47
+ private stopResync?: () => void;
43
48
 
44
49
  constructor(
45
50
  private readonly transport: EventBus,
@@ -47,6 +52,7 @@ export class EventsService {
47
52
  options: EventsServiceOptions = {},
48
53
  ) {
49
54
  this.retryMs = options.retryMs ?? 3_000;
55
+ this.maxStreamsPerUser = options.maxStreamsPerUser ?? 8;
50
56
  this.streamOptions = {
51
57
  queueLimit: options.queueLimit ?? 256,
52
58
  heartbeatMs: options.heartbeatMs ?? 5_000,
@@ -69,6 +75,11 @@ export class EventsService {
69
75
  }
70
76
  this.deliver(envelope);
71
77
  });
78
+ // Events published while the transport was down never arrive, so open
79
+ // streams are told to reload once it is back.
80
+ this.stopResync = this.transport.onInterruption?.(() => {
81
+ for (const stream of this.streams) stream.resync();
82
+ });
72
83
  this.unregisterReadiness = this.readiness.register("events", () => this.transport.ready());
73
84
  }
74
85
 
@@ -93,7 +104,9 @@ export class EventsService {
93
104
 
94
105
  /**
95
106
  * Opens a stream for `subscriber`. Without `topics` the stream receives every
96
- * topic that has no authorizer; guarded topics must be requested by name.
107
+ * topic that has no authorizer; guarded topics must be requested by name. A
108
+ * user beyond the per-user limit is refused rather than closing an older
109
+ * stream, which would make that client reconnect and refuse the next one.
97
110
  */
98
111
  async open(
99
112
  subscriber: EventSubscriber,
@@ -109,6 +122,10 @@ export class EventsService {
109
122
  );
110
123
  }
111
124
  }
125
+ // No await between this check and attach(), so concurrent opens cannot pass it together.
126
+ if ((this.byUser.get(subscriber.userId)?.size ?? 0) >= this.maxStreamsPerUser) {
127
+ throw new AppException("EVENTS_STREAM_LIMIT", "Too many open event streams", 429);
128
+ }
112
129
  const stream = new EventStream(
113
130
  subscriber,
114
131
  topics ? new Set(topics) : undefined,
@@ -122,6 +139,7 @@ export class EventsService {
122
139
 
123
140
  async close(): Promise<void> {
124
141
  this.unregisterReadiness?.();
142
+ this.stopResync?.();
125
143
  await this.unsubscribe?.();
126
144
  for (const stream of [...this.streams]) stream.close();
127
145
  }
@@ -142,6 +160,9 @@ export class EventsService {
142
160
 
143
161
  private attach(stream: EventStream): void {
144
162
  this.streams.add(stream);
163
+ const held = this.byUser.get(stream.subscriber.userId) ?? new Set<EventStream>();
164
+ held.add(stream);
165
+ this.byUser.set(stream.subscriber.userId, held);
145
166
  if (!stream.topics) {
146
167
  this.everyTopic.add(stream);
147
168
  return;
@@ -155,6 +176,9 @@ export class EventsService {
155
176
 
156
177
  private detach(stream: EventStream): void {
157
178
  this.streams.delete(stream);
179
+ const held = this.byUser.get(stream.subscriber.userId);
180
+ held?.delete(stream);
181
+ if (held?.size === 0) this.byUser.delete(stream.subscriber.userId);
158
182
  if (!stream.topics) {
159
183
  this.everyTopic.delete(stream);
160
184
  return;
@@ -119,6 +119,52 @@ test("sse: streams require a session and a valid topic list", async ({ playwrigh
119
119
  }
120
120
  });
121
121
 
122
+ function openStream(cookie: string, controller: AbortController): Promise<Response> {
123
+ return fetch(`${base}/api/events/stream?topics=${uniqueTopic()}`, {
124
+ headers: { ...origin, cookie },
125
+ signal: controller.signal,
126
+ });
127
+ }
128
+
129
+ test("sse: a user's open streams are capped per replica", async ({ playwright }) => {
130
+ const member = await newContext(playwright);
131
+ const other = await newContext(playwright);
132
+ const controllers: AbortController[] = [];
133
+ try {
134
+ test.skip(!(await signUp(member)), "requires the auth module");
135
+ expect(await signUp(other)).toBeDefined();
136
+ const cookie = await cookieHeader(member);
137
+ // The limit is SSE_MAX_STREAMS_PER_USER on the server, so open until refused.
138
+ let refused: Response | undefined;
139
+ for (let attempt = 0; attempt < 64 && !refused; attempt += 1) {
140
+ const controller = new AbortController();
141
+ const response = await openStream(cookie, controller);
142
+ if (response.status === 200) controllers.push(controller);
143
+ else refused = response;
144
+ }
145
+ expect(controllers.length).toBeGreaterThan(0);
146
+ expect(refused?.status).toBe(429);
147
+ expect((await refused?.json()).error.code).toBe("EVENTS_STREAM_LIMIT");
148
+
149
+ // Another user is unaffected, and closing a stream frees its slot.
150
+ const otherController = new AbortController();
151
+ controllers.push(otherController);
152
+ expect((await openStream(await cookieHeader(other), otherController)).status).toBe(200);
153
+ controllers.shift()?.abort();
154
+ await expect.poll(async () => {
155
+ const controller = new AbortController();
156
+ const response = await openStream(cookie, controller);
157
+ if (response.status === 200) controllers.push(controller);
158
+ else controller.abort();
159
+ return response.status;
160
+ }, { timeout: 8_000 }).toBe(200);
161
+ } finally {
162
+ for (const controller of controllers) controller.abort();
163
+ await member.dispose();
164
+ await other.dispose();
165
+ }
166
+ });
167
+
122
168
  test("sse: only administrators may publish", async ({ playwright }) => {
123
169
  const member = await newContext(playwright);
124
170
  try {
@@ -17,7 +17,9 @@
17
17
  "targetApp": "api",
18
18
  "env": [
19
19
  "# Maximum encoded Server-Sent Event payload",
20
- "SSE_MAX_EVENT_BYTES=65536"
20
+ "SSE_MAX_EVENT_BYTES=65536",
21
+ "# Open event streams per user on each API replica",
22
+ "SSE_MAX_STREAMS_PER_USER=8"
21
23
  ],
22
24
  "inject": [
23
25
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { eventsModule } from \"./events/events.module\";" },
@@ -42,6 +42,14 @@ Merge added module settings from `.env.example` into an existing `.env`.
42
42
 
43
43
  - **Ports: web = 5001, api = 5002.** The web app reaches the API only through its
44
44
  SvelteKit server proxy (`/api/*`), never with a direct browser `fetch`.
45
+ - The proxy (`apps/web/src/lib/server/backend-proxy.ts`) is managed and passes only
46
+ allowlisted headers. List extra request and response header names in the
47
+ app-owned `apps/web/src/lib/server/proxy-headers.ts`
48
+ (`APP_FORWARDED_REQUEST_HEADERS`, `APP_RELAYED_RESPONSE_HEADERS`); never edit the
49
+ managed lists. Cap a route's request body in the app-owned
50
+ `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
+ `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
+ uploads larger than it must use a direct API path (`apiPaths`) instead.
45
53
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
46
54
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
47
55
  - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
@@ -92,7 +100,7 @@ Useful commands:
92
100
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
93
101
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
94
102
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
95
- - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
103
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
96
104
  - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
97
105
 
98
106
  ## Commit conventions
@@ -63,8 +63,17 @@ the same topology, with one route and no project-specific host port.
63
63
 
64
64
  When the API exposes WebSocket endpoints, add their exact paths to
65
65
  `.podokit/dev.json` under `webSocketPaths`. The shared gateway sends only those paths
66
- directly to the API and leaves every other path on the web app. Add `publicUrl` to the
67
- same file when an HTTPS tunnel preserves its public `Host` header.
66
+ directly to the API and leaves every other path on the web app. Routes that accept
67
+ bodies larger than the web server's `BODY_SIZE_LIMIT`, such as file uploads, go under
68
+ `apiPaths` as prefixes like `/api/files/uploads/`; the gateway removes `/api` and
69
+ sends them to the API without the web proxy. `podo add` adds the paths a module
70
+ declares, such as `file-upload`'s, to this file and to every deployment profile. Add `publicUrl` to the same file when
71
+ an HTTPS tunnel preserves its public `Host` header.
72
+
73
+ The containers read `.env.docker`, which Git ignores. `dev` commands create it from
74
+ the committed `.env.docker.example` when it is missing, as in a fresh clone, and fill
75
+ each empty `*_SECRET` value with a random one; `dev env` does only that. Keep shared
76
+ defaults in the example and credentials only in `.env.docker`.
68
77
 
69
78
  In a second terminal, apply the included Todo migration and use the lifecycle helpers:
70
79
 
@@ -80,7 +89,9 @@ In a second terminal, apply the included Todo migration and use the lifecycle he
80
89
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
81
90
  `storage`, and `queue` when installed modules require Redis, Silo, or a worker.
82
91
  `dev up` uses the same shared gateway and module profiles without keeping Compose
83
- Watch attached.
92
+ Watch attached. It rebuilds the images from the current source first, because Compose
93
+ Watch syncs edits into running containers only: a container recreated from an older
94
+ image would lose them. Pass `--no-build` to start from the existing images.
84
95
  You can still activate an additional Compose profile explicitly:
85
96
 
86
97
  ```bash
@@ -88,7 +99,8 @@ You can still activate an additional Compose profile explicitly:
88
99
  ```
89
100
 
90
101
  Explicit profile flags are preserved for other lifecycle commands. `dev down`
91
- activates all profiles while removing this project's stack and route. If it is
102
+ first stops this project's running `dev watch`, so the next `dev watch` starts
103
+ cleanly, then activates all profiles while removing this project's stack and route. If it is
92
104
  the final registered
93
105
  project, it also removes the shared gateway and network. Source changes are synced
94
106
  through Compose Watch, including Vite HMR on the same portless browser origin.
@@ -149,5 +161,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
149
161
 
150
162
  For a Docker Compose deployment with API WebSockets, list their exact paths under
151
163
  `exposure.webSocketPaths`. The driver keeps one published port, routes those paths
152
- to API, and sends every other request to web. If an external reverse proxy supplies
164
+ to API, and sends every other request to web. Large-upload routes listed under
165
+ `exposure.apiPaths` reach the API the same way, with `/api` removed. If an external reverse proxy supplies
153
166
  forwarding headers, list only its direct CIDR blocks in `exposure.trustedProxyCidrs`.
package/dist/update.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
2
  import { type Tier } from "./lockfile";
3
+ import type { ProviderCapability } from "@podosoft/podokit-runtime";
3
4
  import type { Toolchain } from "./toolchain";
5
+ import { type ApiPathResult } from "./module-api-paths";
4
6
  /**
5
7
  * `podo update` planner. For now it produces a dry-run plan only: it assembles
6
8
  * the new-version tree from the installed CLI's templates and compares it to the
@@ -17,12 +19,26 @@ export interface FileChange {
17
19
  /** Why the action was chosen (for the report). */
18
20
  note: string;
19
21
  }
22
+ /** A module the target version needs that the project does not record yet. */
23
+ export interface AddedModule {
24
+ name: string;
25
+ /** Installed or added module that needs it. */
26
+ requiredBy: string;
27
+ /** Provider capability it implements, when added for a capability requirement. */
28
+ capability?: ProviderCapability;
29
+ /** Selected provider it implements. */
30
+ provider?: string;
31
+ }
20
32
  export interface UpdatePlan {
21
33
  fromVersion: string;
22
34
  toVersion: string;
23
35
  template: string;
24
36
  modules: string[];
37
+ /** Modules the update adds, ordered ahead of the modules that need them. */
38
+ addedModules: AddedModule[];
25
39
  changes: FileChange[];
40
+ /** Direct API paths that modules newly declare, per configuration file. */
41
+ apiPaths: ApiPathResult;
26
42
  }
27
43
  /**
28
44
  * Build the update plan. `templatesDir` is the installed CLI's template set (the
@@ -51,6 +67,8 @@ export interface ApplyOptions {
51
67
  abortOnConflict?: boolean;
52
68
  }
53
69
  export interface ApplyResult {
70
+ /** Modules added because the target version requires them. */
71
+ addedModules: string[];
54
72
  written: string[];
55
73
  /** Files relocated by module update migrations. */
56
74
  moved: {
@@ -62,6 +80,8 @@ export interface ApplyResult {
62
80
  merged: string[];
63
81
  /** Files written with conflict markers for manual resolution. */
64
82
  conflicts: string[];
83
+ /** Direct API paths added to `.podokit/dev.json` and deployment profiles. */
84
+ apiPaths: ApiPathResult;
65
85
  }
66
86
  /**
67
87
  * Apply an update to the working copy. Clean updates and additions are written,
@@ -71,5 +91,7 @@ export interface ApplyResult {
71
91
  * stamps the new version.
72
92
  */
73
93
  export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
94
+ /** Why an update adds a module, for the plan report. */
95
+ export declare function addedModuleReason(module: AddedModule): string;
74
96
  /** Count actions for a one-line summary. */
75
97
  export declare function summarize(plan: UpdatePlan): Record<Action, number>;
package/dist/update.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.planUpdate = planUpdate;
4
4
  exports.applyUpdate = applyUpdate;
5
+ exports.addedModuleReason = addedModuleReason;
5
6
  exports.summarize = summarize;
6
7
  const node_child_process_1 = require("node:child_process");
7
8
  const node_fs_1 = require("node:fs");
@@ -13,6 +14,7 @@ const lockfile_1 = require("./lockfile");
13
14
  const inspect_1 = require("./inspect");
14
15
  const add_1 = require("./add");
15
16
  const module_migrations_1 = require("./module-migrations");
17
+ const module_api_paths_1 = require("./module-api-paths");
16
18
  function treeText(tree, path) {
17
19
  const file = tree.get(path);
18
20
  if (!file)
@@ -25,17 +27,21 @@ function diskContent(projectRoot, path) {
25
27
  }
26
28
  /**
27
29
  * Expand the recorded module list against the target manifests. A module may
28
- * gain a new requirement after it was first added to a project; updates must
29
- * apply that requirement before rebuilding the dependent module or the target
30
- * tree can contain imports and wiring for files that were never installed.
30
+ * gain a new requirement after it was first added to a project, either another
31
+ * module or a provider capability. Updates must apply that requirement before
32
+ * the dependent module: modules configure in this order, and the target tree
33
+ * would otherwise wire files that were never installed. A capability resolves
34
+ * to the module implementing the project's selected provider, as `podo add`
35
+ * would choose it.
31
36
  */
32
- function targetModules(projectRoot, templatesDir, modules) {
37
+ function targetModules(projectRoot, templatesDir, modules, providers) {
33
38
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
34
39
  const recorded = new Map(modules.map((module) => [module.name, module]));
35
40
  const ordered = [];
41
+ const added = [];
36
42
  const visiting = new Set();
37
43
  const visited = new Set();
38
- const visit = (name, requiredBy) => {
44
+ const visit = (name, requiredBy, capability) => {
39
45
  if (visited.has(name))
40
46
  return;
41
47
  if (visiting.has(name)) {
@@ -52,12 +58,24 @@ function targetModules(projectRoot, templatesDir, modules) {
52
58
  const moduleManifest = (0, add_1.readModuleManifest)(resolved.dir);
53
59
  for (const required of moduleManifest.requires ?? [])
54
60
  visit(required, name);
61
+ for (const required of moduleManifest.requiresCapabilities ?? []) {
62
+ visit((0, add_1.selectedProviderModule)(modulesDir, projectRoot, providers, required), name, required);
63
+ }
55
64
  visiting.delete(name);
56
65
  visited.add(name);
66
+ if (!existing && requiredBy) {
67
+ added.push({
68
+ name: resolved.name,
69
+ requiredBy,
70
+ ...(capability ? { capability, provider: providers[capability] } : {}),
71
+ });
72
+ }
57
73
  ordered.push({
58
74
  ...(existing ?? {
59
75
  name: resolved.name,
60
76
  addedWith: (0, lockfile_1.podokitVersion)(),
77
+ // A freshly added module ships its current layout, as `podo add` records it.
78
+ appliedMigrations: (moduleManifest.migrations ?? []).map((migration) => migration.id),
61
79
  }),
62
80
  order: ordered.length,
63
81
  ...(resolved.packageName
@@ -71,7 +89,19 @@ function targetModules(projectRoot, templatesDir, modules) {
71
89
  for (const module of [...modules].sort((a, b) => a.order - b.order)) {
72
90
  visit(module.name);
73
91
  }
74
- return ordered;
92
+ return { modules: ordered, added };
93
+ }
94
+ function targetApiPaths(projectRoot, templatesDir, modules) {
95
+ const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
96
+ const declared = new Map();
97
+ const pending = [];
98
+ for (const module of modules) {
99
+ const resolved = (0, add_1.resolveModule)(module.packageName ?? module.name, modulesDir, projectRoot);
100
+ const paths = resolved ? ((0, add_1.readModuleManifest)(resolved.dir).apiPaths ?? []) : [];
101
+ declared.set(module.name, paths);
102
+ pending.push(...(0, module_api_paths_1.pendingApiPaths)(module, paths));
103
+ }
104
+ return { declared, pending };
75
105
  }
76
106
  function targetManagedOverrides(projectRoot, templatesDir, modules, current = []) {
77
107
  const overrides = new Set(current);
@@ -102,7 +132,7 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
102
132
  const lock = (0, lockfile_1.readFilesLock)(projectRoot);
103
133
  if (!manifest || !lock)
104
134
  throw new inspect_1.NotAProjectError();
105
- const modules = targetModules(projectRoot, templatesDir, manifest.modules);
135
+ const { modules, added } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
106
136
  const newTree = (0, assemble_1.assembleProject)({
107
137
  templatesDir,
108
138
  template: manifest.template,
@@ -205,12 +235,24 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
205
235
  note: edited ? "you edited this; update would need a merge" : "clean update",
206
236
  });
207
237
  }
238
+ // A configuration file the update creates, such as a missing
239
+ // .podokit/dev.json, receives the paths after it is written.
240
+ const created = new Set(changes.filter((change) => change.action === "add").map((change) => change.path));
241
+ const apiPaths = options.onlyPaths
242
+ ? { changed: [], skipped: [] }
243
+ : (0, module_api_paths_1.addApiPaths)(projectRoot, targetApiPaths(projectRoot, templatesDir, modules).pending, {
244
+ dryRun: true,
245
+ read: (file) => diskContent(projectRoot, file)?.toString("utf8")
246
+ ?? (created.has(file) ? treeText(newTree, file) : null),
247
+ });
208
248
  return {
209
249
  fromVersion: manifest.podokitVersion,
210
250
  toVersion: (0, lockfile_1.podokitVersion)(),
211
251
  template: manifest.template,
212
252
  modules: modules.map((module) => module.name),
253
+ addedModules: added,
213
254
  changes,
255
+ apiPaths,
214
256
  };
215
257
  }
216
258
  function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
@@ -333,7 +375,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
333
375
  forceManagedPaths: options.forceManagedPaths,
334
376
  onlyPaths: options.onlyPaths,
335
377
  });
336
- const modules = targetModules(projectRoot, templatesDir, manifest.modules);
378
+ const { modules } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
337
379
  const newTree = (0, assemble_1.assembleProject)({
338
380
  templatesDir,
339
381
  template: manifest.template,
@@ -396,11 +438,13 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
396
438
  }
397
439
  }
398
440
  const result = {
441
+ addedModules: plan.addedModules.map((module) => module.name),
399
442
  written: [],
400
443
  moved: (0, module_migrations_1.applyModuleMigrations)(projectRoot, migrationPlan),
401
444
  removed: [],
402
445
  merged: [],
403
446
  conflicts: [],
447
+ apiPaths: { changed: [], skipped: [] },
404
448
  };
405
449
  for (const change of plan.changes) {
406
450
  const newText = treeText(newTree, change.path);
@@ -428,6 +472,9 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
428
472
  result.merged.push(change.path);
429
473
  }
430
474
  }
475
+ const apiPaths = options.onlyPaths ? undefined : targetApiPaths(projectRoot, templatesDir, modules);
476
+ if (apiPaths)
477
+ result.apiPaths = (0, module_api_paths_1.addApiPaths)(projectRoot, apiPaths.pending);
431
478
  // Keep the assembled target as the managed baseline. A merged working file
432
479
  // intentionally remains drifted so a future update performs another 3-way
433
480
  // merge instead of treating the user's lines as clean generated output.
@@ -447,6 +494,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
447
494
  ...(appliedMigrations.get(module.name) ?? []),
448
495
  ]),
449
496
  ];
497
+ const appliedApiPaths = [
498
+ ...new Set([
499
+ ...(module.appliedApiPaths ?? []),
500
+ ...(apiPaths?.declared.get(module.name) ?? []),
501
+ ]),
502
+ ];
450
503
  return {
451
504
  ...module,
452
505
  ...(resolved?.packageName
@@ -456,6 +509,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
456
509
  }
457
510
  : {}),
458
511
  ...(applied.length ? { appliedMigrations: applied } : {}),
512
+ ...(appliedApiPaths.length ? { appliedApiPaths } : {}),
459
513
  };
460
514
  });
461
515
  (0, lockfile_1.writeManifest)(projectRoot, {
@@ -469,6 +523,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
469
523
  });
470
524
  return result;
471
525
  }
526
+ /** Why an update adds a module, for the plan report. */
527
+ function addedModuleReason(module) {
528
+ return module.capability
529
+ ? `${module.capability} provider "${module.provider}" required by ${module.requiredBy}`
530
+ : `required by ${module.requiredBy}`;
531
+ }
472
532
  /** Count actions for a one-line summary. */
473
533
  function summarize(plan) {
474
534
  const counts = {