@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -1,87 +1,244 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
2
2
 
3
3
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
4
  const secondaryApi = process.env.E2E_SECONDARY_API_URL;
5
5
  const origin = { origin: base };
6
+ // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
7
+ // spec also compiles in apps that do not install that module.
8
+ const admin = {
9
+ email: "admin@example.com",
10
+ password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
11
+ };
6
12
 
7
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
8
- const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
13
+ type Playwright = PlaywrightWorkerArgs["playwright"];
14
+
15
+ interface SessionUser {
16
+ id: string;
17
+ roles: string[];
18
+ }
19
+
20
+ function newContext(playwright: Playwright): Promise<APIRequestContext> {
21
+ return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
22
+ }
23
+
24
+ async function sessionUser(ctx: APIRequestContext): Promise<SessionUser | undefined> {
25
+ const response = await ctx.get("/api/auth/get-session");
26
+ if (!response.ok()) return undefined;
27
+ const session = await response.json() as { user?: { id?: string; role?: string | null } } | null;
28
+ if (!session?.user?.id) return undefined;
29
+ const roles = (session.user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
30
+ return { id: session.user.id, roles };
31
+ }
32
+
33
+ /** Resolves undefined when the auth module is missing or sign-up does not start a session. */
34
+ async function signUp(ctx: APIRequestContext): Promise<SessionUser | undefined> {
9
35
  const email = `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
10
- await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "E" } }).catch(() => undefined);
11
- return ctx;
36
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "E" } });
37
+ return sessionUser(ctx);
38
+ }
39
+
40
+ /** Resolves false when no administrator session is available. */
41
+ async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
42
+ const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
43
+ return response.ok() && ((await sessionUser(ctx))?.roles ?? []).includes("admin");
44
+ }
45
+
46
+ async function cookieHeader(ctx: APIRequestContext): Promise<string> {
47
+ return (await ctx.storageState()).cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
48
+ }
49
+
50
+ function uniqueTopic(): string {
51
+ return `spec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12
52
  }
13
53
 
14
- test("sse: publish accepts a message", async ({ playwright }) => {
15
- const ctx = await session(playwright);
16
- const pub = await ctx.post("/api/events", { data: { message: `hi-${Date.now()}` } });
17
- expect(pub.ok()).toBeTruthy();
18
- expect(await pub.json()).toMatchObject({ ok: true });
19
- await ctx.dispose();
54
+ class EventStreamReader {
55
+ private readonly decoder = new TextDecoder();
56
+ private buffered = "";
57
+
58
+ private constructor(
59
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>,
60
+ private readonly controller: AbortController,
61
+ ) {}
62
+
63
+ /** Opens a stream and waits for its retry hint, which proves the subscription is live. */
64
+ static async open(ctx: APIRequestContext, topics: string): Promise<EventStreamReader> {
65
+ const controller = new AbortController();
66
+ const response = await fetch(`${base}/api/events/stream?topics=${encodeURIComponent(topics)}`, {
67
+ headers: { ...origin, cookie: await cookieHeader(ctx) },
68
+ signal: controller.signal,
69
+ });
70
+ expect(response.status).toBe(200);
71
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
72
+ const reader = response.body?.getReader();
73
+ if (!reader) throw new Error("event stream has no body");
74
+ const stream = new EventStreamReader(reader, controller);
75
+ await stream.until("retry:");
76
+ return stream;
77
+ }
78
+
79
+ /** Returns everything received up to and including `text`. */
80
+ async until(text: string, timeoutMs = 8_000): Promise<string> {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (!this.buffered.includes(text)) {
83
+ const remaining = deadline - Date.now();
84
+ if (remaining <= 0) throw new Error(`timed out waiting for ${text}`);
85
+ let timer: ReturnType<typeof setTimeout> | undefined;
86
+ const timeout = new Promise<"timeout">((resolve) => {
87
+ timer = setTimeout(() => resolve("timeout"), remaining);
88
+ });
89
+ const result = await Promise.race([this.reader.read(), timeout]);
90
+ clearTimeout(timer);
91
+ if (result === "timeout") continue;
92
+ if (result.done) throw new Error(`stream ended before ${text}`);
93
+ this.buffered += this.decoder.decode(result.value, { stream: true });
94
+ }
95
+ const end = this.buffered.indexOf(text) + text.length;
96
+ const seen = this.buffered.slice(0, end);
97
+ this.buffered = this.buffered.slice(end);
98
+ return seen;
99
+ }
100
+
101
+ close(): void {
102
+ this.controller.abort();
103
+ }
104
+ }
105
+
106
+ test("sse: streams require a session and a valid topic list", async ({ playwright }) => {
107
+ const anonymous = await newContext(playwright);
108
+ const member = await newContext(playwright);
109
+ try {
110
+ test.skip(!(await signUp(member)), "requires the auth module");
111
+ const unauthenticated = await fetch(`${base}/api/events/stream?topics=message`, { headers: origin });
112
+ expect(unauthenticated.status).toBe(401);
113
+ const invalid = await member.get("/api/events/stream?topics=bad%20topic");
114
+ expect(invalid.status()).toBe(400);
115
+ expect((await invalid.json()).error.code).toBe("EVENTS_TOPICS_INVALID");
116
+ } finally {
117
+ await anonymous.dispose();
118
+ await member.dispose();
119
+ }
120
+ });
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
+ }
20
166
  });
21
167
 
22
- test("sse: a published message is delivered over the event stream @smoke", async ({ playwright }) => {
23
- const ctx = await session(playwright);
24
- // The stream is behind the auth guard — carry the session cookie on the raw fetch.
25
- const cookies = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
26
- const ac = new AbortController();
27
- const stream = await fetch(`${base}/api/events/stream`, { headers: { ...origin, cookie: cookies }, signal: ac.signal }).catch(() => null);
28
- test.skip(!stream || !stream.ok || !stream.body, "sse stream not reachable");
29
- expect(stream!.headers.get("content-type")).toContain("text/event-stream");
30
-
31
- // Subscriber is attached now; publish, then read until the message arrives.
32
- const msg = `sse-${Date.now()}`;
33
- await ctx.post("/api/events", { data: { message: msg } });
34
-
35
- const reader = stream!.body!.getReader();
36
- const decoder = new TextDecoder();
37
- let seen = false;
38
- const deadline = Date.now() + 8000;
39
- while (Date.now() < deadline && !seen) {
40
- const { value, done } = await reader.read();
41
- if (done) break;
42
- if (decoder.decode(value).includes(msg)) seen = true;
168
+ test("sse: only administrators may publish", async ({ playwright }) => {
169
+ const member = await newContext(playwright);
170
+ try {
171
+ test.skip(!(await signUp(member)), "requires the auth module");
172
+ const published = await member.post("/api/events", { data: { message: "hello" } });
173
+ expect(published.status()).toBe(403);
174
+ expect((await published.json()).error.code).toBe("ADMIN_REQUIRED");
175
+ } finally {
176
+ await member.dispose();
177
+ }
178
+ });
179
+
180
+ test("sse: an admin publish reaches only the addressed user @smoke", async ({ playwright }) => {
181
+ const adminCtx = await newContext(playwright);
182
+ const aliceCtx = await newContext(playwright);
183
+ const bobCtx = await newContext(playwright);
184
+ const streams: EventStreamReader[] = [];
185
+ try {
186
+ test.skip(!(await signInAdmin(adminCtx)), "requires the auth module and the seeded administrator");
187
+ const alice = await signUp(aliceCtx);
188
+ expect(alice).toBeDefined();
189
+ expect(await signUp(bobCtx)).toBeDefined();
190
+ const topic = uniqueTopic();
191
+ const aliceStream = await EventStreamReader.open(aliceCtx, topic);
192
+ const bobStream = await EventStreamReader.open(bobCtx, topic);
193
+ streams.push(aliceStream, bobStream);
194
+
195
+ const secret = `secret-${Date.now()}`;
196
+ const done = `done-${Date.now()}`;
197
+ const targeted = await adminCtx.post("/api/events", {
198
+ data: { topic, audience: { userIds: [alice?.id] }, message: secret },
199
+ });
200
+ expect(targeted.status()).toBe(201);
201
+ expect(await targeted.json()).toEqual({ ok: true });
202
+ expect((await adminCtx.post("/api/events", { data: { topic, message: done } })).status()).toBe(201);
203
+
204
+ const aliceSaw = await aliceStream.until(done);
205
+ expect(aliceSaw).toContain(JSON.stringify({ topic, data: { message: secret } }));
206
+ expect(await bobStream.until(done)).not.toContain(secret);
207
+ } finally {
208
+ for (const stream of streams) stream.close();
209
+ await adminCtx.dispose();
210
+ await aliceCtx.dispose();
211
+ await bobCtx.dispose();
43
212
  }
44
- ac.abort();
45
- expect(seen).toBe(true);
46
- await ctx.dispose();
47
213
  });
48
214
 
49
215
  test("sse: a second API replica delivers through Redis", async ({ playwright }) => {
50
216
  test.skip(!secondaryApi, "a secondary API replica is not configured");
51
- const ctx = await session(playwright);
52
- const cookies = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
53
- const ac = new AbortController();
54
- const stream = await fetch(`${base}/api/events/stream`, {
55
- headers: { ...origin, cookie: cookies },
56
- signal: ac.signal,
57
- });
58
- expect(stream.ok).toBe(true);
59
- expect(stream.body).not.toBeNull();
60
-
61
- const msg = `replica-${Date.now()}`;
62
- const published = await fetch(`${secondaryApi}/events`, {
63
- method: "POST",
64
- headers: {
65
- ...origin,
66
- cookie: cookies,
67
- "content-type": "application/json",
68
- },
69
- body: JSON.stringify({ message: msg }),
70
- });
71
- expect(published.ok).toBe(true);
72
-
73
- const reader = stream.body!.getReader();
74
- const decoder = new TextDecoder();
75
- let seen = 0;
76
- let buffered = "";
77
- const deadline = Date.now() + 8000;
78
- while (Date.now() < deadline && seen === 0) {
79
- const { value, done } = await reader.read();
80
- if (done) break;
81
- buffered += decoder.decode(value, { stream: true });
82
- seen = buffered.split(msg).length - 1;
217
+ const adminCtx = await newContext(playwright);
218
+ const memberCtx = await newContext(playwright);
219
+ let stream: EventStreamReader | undefined;
220
+ try {
221
+ test.skip(!(await signInAdmin(adminCtx)), "requires the auth module and the seeded administrator");
222
+ const member = await signUp(memberCtx);
223
+ expect(member).toBeDefined();
224
+ const topic = uniqueTopic();
225
+ stream = await EventStreamReader.open(memberCtx, topic);
226
+
227
+ const message = `replica-${Date.now()}`;
228
+ const published = await fetch(`${secondaryApi}/events`, {
229
+ method: "POST",
230
+ headers: { ...origin, cookie: await cookieHeader(adminCtx), "content-type": "application/json" },
231
+ body: JSON.stringify({ topic, audience: { userIds: [member?.id] }, message }),
232
+ });
233
+ expect(published.status).toBe(201);
234
+
235
+ const done = `done-${Date.now()}`;
236
+ expect((await adminCtx.post("/api/events", { data: { topic, message: done } })).status()).toBe(201);
237
+ const seen = await stream.until(done);
238
+ expect(seen.split(message).length - 1).toBe(1);
239
+ } finally {
240
+ stream?.close();
241
+ await adminCtx.dispose();
242
+ await memberCtx.dispose();
83
243
  }
84
- ac.abort();
85
- expect(seen).toBe(1);
86
- await ctx.dispose();
87
244
  });
@@ -6,19 +6,34 @@
6
6
  "requiredProjectFiles": [
7
7
  "apps/api/src/health/readiness.service.ts"
8
8
  ],
9
+ "requiredProjectContents": {
10
+ "apps/api/src/core/services.ts": [
11
+ "isAdmin(request: Request): Promise<boolean>;",
12
+ "roles(request: Request): Promise<string[]>;",
13
+ "activeOrganizationId(request: Request): Promise<string | undefined>;",
14
+ "tryResolve<T>(key: ServiceKey<T>): T | undefined {"
15
+ ]
16
+ },
9
17
  "targetApp": "api",
10
18
  "env": [
11
19
  "# Maximum encoded Server-Sent Event payload",
12
- "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"
13
23
  ],
14
24
  "inject": [
15
25
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { eventsModule } from \"./events/events.module\";" },
16
26
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "eventsModule," }
17
27
  ],
18
28
  "instructions": [
19
- "Stream events: curl -N localhost:5002/events/stream",
20
- "Publish a message (in another terminal): curl -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
21
- "Select events=redis for multiple replicas or events=memory for a single process.",
22
- "Inject EventsService and call publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica."
29
+ "Stream as a signed-in user: curl -N -b cookies.txt 'localhost:5002/events/stream?topics=message'",
30
+ "Publish as an administrator (in another terminal): curl -b admin-cookies.txt -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
31
+ "Each data event is a default SSE message whose data is {\"topic\",\"data\"}. A resync event means events were dropped, so reload state.",
32
+ "Publish from a module with EventsService.publish({ topic, audience, data }). An audience of userIds, roles, or organizationId limits who receives it.",
33
+ "Use publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica.",
34
+ "Restrict who may subscribe to a topic with events.topics.register(topic, authorizer).",
35
+ "Streams take the subscriber's roles and active organization from the request identity. Register EVENT_IDENTITY in app.extensions.ts to derive them differently.",
36
+ "In the browser, subscribe(topic, handler) from $lib/events shares one EventSource per tab.",
37
+ "Select events=redis for multiple replicas or events=memory for a single process."
23
38
  ]
24
39
  }
package/dist/update.d.ts CHANGED
@@ -1,5 +1,6 @@
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";
4
5
  /**
5
6
  * `podo update` planner. For now it produces a dry-run plan only: it assembles
@@ -17,11 +18,23 @@ export interface FileChange {
17
18
  /** Why the action was chosen (for the report). */
18
19
  note: string;
19
20
  }
21
+ /** A module the target version needs that the project does not record yet. */
22
+ export interface AddedModule {
23
+ name: string;
24
+ /** Installed or added module that needs it. */
25
+ requiredBy: string;
26
+ /** Provider capability it implements, when added for a capability requirement. */
27
+ capability?: ProviderCapability;
28
+ /** Selected provider it implements. */
29
+ provider?: string;
30
+ }
20
31
  export interface UpdatePlan {
21
32
  fromVersion: string;
22
33
  toVersion: string;
23
34
  template: string;
24
35
  modules: string[];
36
+ /** Modules the update adds, ordered ahead of the modules that need them. */
37
+ addedModules: AddedModule[];
25
38
  changes: FileChange[];
26
39
  }
27
40
  /**
@@ -51,6 +64,8 @@ export interface ApplyOptions {
51
64
  abortOnConflict?: boolean;
52
65
  }
53
66
  export interface ApplyResult {
67
+ /** Modules added because the target version requires them. */
68
+ addedModules: string[];
54
69
  written: string[];
55
70
  /** Files relocated by module update migrations. */
56
71
  moved: {
@@ -71,5 +86,7 @@ export interface ApplyResult {
71
86
  * stamps the new version.
72
87
  */
73
88
  export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
89
+ /** Why an update adds a module, for the plan report. */
90
+ export declare function addedModuleReason(module: AddedModule): string;
74
91
  /** Count actions for a one-line summary. */
75
92
  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");
@@ -25,17 +26,21 @@ function diskContent(projectRoot, path) {
25
26
  }
26
27
  /**
27
28
  * 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.
29
+ * gain a new requirement after it was first added to a project, either another
30
+ * module or a provider capability. Updates must apply that requirement before
31
+ * the dependent module: modules configure in this order, and the target tree
32
+ * would otherwise wire files that were never installed. A capability resolves
33
+ * to the module implementing the project's selected provider, as `podo add`
34
+ * would choose it.
31
35
  */
32
- function targetModules(projectRoot, templatesDir, modules) {
36
+ function targetModules(projectRoot, templatesDir, modules, providers) {
33
37
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
34
38
  const recorded = new Map(modules.map((module) => [module.name, module]));
35
39
  const ordered = [];
40
+ const added = [];
36
41
  const visiting = new Set();
37
42
  const visited = new Set();
38
- const visit = (name, requiredBy) => {
43
+ const visit = (name, requiredBy, capability) => {
39
44
  if (visited.has(name))
40
45
  return;
41
46
  if (visiting.has(name)) {
@@ -52,12 +57,24 @@ function targetModules(projectRoot, templatesDir, modules) {
52
57
  const moduleManifest = (0, add_1.readModuleManifest)(resolved.dir);
53
58
  for (const required of moduleManifest.requires ?? [])
54
59
  visit(required, name);
60
+ for (const required of moduleManifest.requiresCapabilities ?? []) {
61
+ visit((0, add_1.selectedProviderModule)(modulesDir, projectRoot, providers, required), name, required);
62
+ }
55
63
  visiting.delete(name);
56
64
  visited.add(name);
65
+ if (!existing && requiredBy) {
66
+ added.push({
67
+ name: resolved.name,
68
+ requiredBy,
69
+ ...(capability ? { capability, provider: providers[capability] } : {}),
70
+ });
71
+ }
57
72
  ordered.push({
58
73
  ...(existing ?? {
59
74
  name: resolved.name,
60
75
  addedWith: (0, lockfile_1.podokitVersion)(),
76
+ // A freshly added module ships its current layout, as `podo add` records it.
77
+ appliedMigrations: (moduleManifest.migrations ?? []).map((migration) => migration.id),
61
78
  }),
62
79
  order: ordered.length,
63
80
  ...(resolved.packageName
@@ -71,7 +88,7 @@ function targetModules(projectRoot, templatesDir, modules) {
71
88
  for (const module of [...modules].sort((a, b) => a.order - b.order)) {
72
89
  visit(module.name);
73
90
  }
74
- return ordered;
91
+ return { modules: ordered, added };
75
92
  }
76
93
  function targetManagedOverrides(projectRoot, templatesDir, modules, current = []) {
77
94
  const overrides = new Set(current);
@@ -102,7 +119,7 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
102
119
  const lock = (0, lockfile_1.readFilesLock)(projectRoot);
103
120
  if (!manifest || !lock)
104
121
  throw new inspect_1.NotAProjectError();
105
- const modules = targetModules(projectRoot, templatesDir, manifest.modules);
122
+ const { modules, added } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
106
123
  const newTree = (0, assemble_1.assembleProject)({
107
124
  templatesDir,
108
125
  template: manifest.template,
@@ -210,6 +227,7 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
210
227
  toVersion: (0, lockfile_1.podokitVersion)(),
211
228
  template: manifest.template,
212
229
  modules: modules.map((module) => module.name),
230
+ addedModules: added,
213
231
  changes,
214
232
  };
215
233
  }
@@ -333,7 +351,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
333
351
  forceManagedPaths: options.forceManagedPaths,
334
352
  onlyPaths: options.onlyPaths,
335
353
  });
336
- const modules = targetModules(projectRoot, templatesDir, manifest.modules);
354
+ const { modules } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
337
355
  const newTree = (0, assemble_1.assembleProject)({
338
356
  templatesDir,
339
357
  template: manifest.template,
@@ -396,6 +414,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
396
414
  }
397
415
  }
398
416
  const result = {
417
+ addedModules: plan.addedModules.map((module) => module.name),
399
418
  written: [],
400
419
  moved: (0, module_migrations_1.applyModuleMigrations)(projectRoot, migrationPlan),
401
420
  removed: [],
@@ -469,6 +488,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
469
488
  });
470
489
  return result;
471
490
  }
491
+ /** Why an update adds a module, for the plan report. */
492
+ function addedModuleReason(module) {
493
+ return module.capability
494
+ ? `${module.capability} provider "${module.provider}" required by ${module.requiredBy}`
495
+ : `required by ${module.requiredBy}`;
496
+ }
472
497
  /** Count actions for a one-line summary. */
473
498
  function summarize(plan) {
474
499
  const counts = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.1.6",
3
+ "version": "1.3.0",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@podosoft/podokit-runtime": "^0.2.0",
48
+ "@podosoft/podokit-runtime": "^0.3.0",
49
49
  "@podosoft/podokit-template-engine": "^0.4.2"
50
50
  }
51
51
  }