@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
@@ -0,0 +1,145 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import {
4
+ MemoryEventBus,
5
+ type JobHandler,
6
+ type JobQueue,
7
+ type JobReference,
8
+ } from "@podosoft/podokit-runtime";
9
+ import { Elysia } from "elysia";
10
+ import type { RequestIdentity } from "../core/services";
11
+ import type { EventStream, StreamFrame } from "../events/event-stream";
12
+ import { EventsService } from "../events/events.service";
13
+ import { ReadinessService } from "../health/readiness.service";
14
+ import { progressRoutes } from "./job-progress.module";
15
+ import { JOB_PROGRESS_TOPIC } from "./progress.processor";
16
+ import { ProgressWorker } from "./progress.worker";
17
+
18
+ interface FakeQueue extends JobQueue {
19
+ enqueued: Array<{ name: string; payload: unknown }>;
20
+ run(payload: unknown, job: JobReference): Promise<void>;
21
+ }
22
+
23
+ function fakeQueue(): FakeQueue {
24
+ const handlers = new Map<string, JobHandler>();
25
+ const enqueued: Array<{ name: string; payload: unknown }> = [];
26
+ return {
27
+ enqueued,
28
+ enqueue: async (name, payload) => {
29
+ enqueued.push({ name, payload });
30
+ return { id: `job-${enqueued.length}`, name };
31
+ },
32
+ get: async () => null,
33
+ process: async (name, handler) => {
34
+ handlers.set(name, handler);
35
+ return () => {
36
+ handlers.delete(name);
37
+ };
38
+ },
39
+ run: async (payload, job) => {
40
+ const handler = handlers.get(job.name);
41
+ if (!handler) throw new Error(`no handler for ${job.name}`);
42
+ await handler(payload, job);
43
+ },
44
+ close: () => undefined,
45
+ };
46
+ }
47
+
48
+ const cleanups: Array<() => Promise<void>> = [];
49
+
50
+ afterEach(async () => {
51
+ for (const cleanup of cleanups.splice(0)) await cleanup();
52
+ });
53
+
54
+ async function harness(): Promise<{ events: EventsService; queue: FakeQueue }> {
55
+ const events = new EventsService(new MemoryEventBus(), new ReadinessService());
56
+ await events.connect();
57
+ const queue = fakeQueue();
58
+ const worker = new ProgressWorker(queue, (event) => events.publishAsync(event), async () => undefined);
59
+ await worker.connect();
60
+ cleanups.push(async () => {
61
+ await worker.close();
62
+ await events.close();
63
+ });
64
+ return { events, queue };
65
+ }
66
+
67
+ function stream(events: EventsService, userId: string): Promise<EventStream> {
68
+ return events.open({ userId, roles: [] }, [JOB_PROGRESS_TOPIC, "marker"], new AbortController().signal);
69
+ }
70
+
71
+ function decoded(frame: StreamFrame | undefined): unknown {
72
+ return frame?.kind === "event" ? JSON.parse(frame.data) : frame;
73
+ }
74
+
75
+ async function progressBeforeMarker(events: EventsService, target: EventStream): Promise<unknown[]> {
76
+ await events.publishAsync({ topic: "marker", data: "end" });
77
+ const frames: unknown[] = [];
78
+ for (;;) {
79
+ const frame = decoded(await target.next());
80
+ if (JSON.stringify(frame) === JSON.stringify({ topic: "marker", data: "end" })) return frames;
81
+ frames.push(frame);
82
+ }
83
+ }
84
+
85
+ describe("progress worker", () => {
86
+ it("sends a job's progress only to the user who started it", async () => {
87
+ const { events, queue } = await harness();
88
+ const owner = await stream(events, "alice");
89
+ const other = await stream(events, "bob");
90
+
91
+ await queue.run({ steps: 2, ownerId: "alice" }, { id: "job-1", name: "progress" });
92
+
93
+ expect(await progressBeforeMarker(events, owner)).toEqual([
94
+ { topic: JOB_PROGRESS_TOPIC, data: { jobId: "job-1", progress: 50 } },
95
+ { topic: JOB_PROGRESS_TOPIC, data: { jobId: "job-1", progress: 100 } },
96
+ ]);
97
+ expect(await progressBeforeMarker(events, other)).toEqual([]);
98
+ });
99
+
100
+ it("publishes nothing for a job without an owner", async () => {
101
+ const { events, queue } = await harness();
102
+ const listener = await stream(events, "alice");
103
+ await queue.run({ steps: 2 }, { id: "legacy", name: "progress" });
104
+ expect(await progressBeforeMarker(events, listener)).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("progress routes", () => {
109
+ const identity = (userId: string | undefined): RequestIdentity => ({
110
+ userId: async () => userId,
111
+ isAdmin: async () => false,
112
+ roles: async () => [],
113
+ activeOrganizationId: async () => undefined,
114
+ });
115
+
116
+ async function start(queue: JobQueue, userId: string | undefined): Promise<Response> {
117
+ const app = new Elysia()
118
+ .onError(({ error, set }) => {
119
+ if (!(error instanceof AppException)) return undefined;
120
+ set.status = error.statusCode;
121
+ return { code: error.code };
122
+ })
123
+ .use(progressRoutes(queue, identity(userId)));
124
+ return app.handle(new Request("http://localhost/progress", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify({ steps: 3 }),
128
+ }));
129
+ }
130
+
131
+ it("records the caller as the job owner", async () => {
132
+ const queue = fakeQueue();
133
+ const response = await start(queue, "alice");
134
+ expect(response.status).toBe(201);
135
+ expect(await response.json()).toEqual({ jobId: "job-1" });
136
+ expect(queue.enqueued).toEqual([{ name: "progress", payload: { steps: 3, ownerId: "alice" } }]);
137
+ });
138
+
139
+ it("requires a signed-in user", async () => {
140
+ const queue = fakeQueue();
141
+ const response = await start(queue, undefined);
142
+ expect(response.status).toBe(401);
143
+ expect(queue.enqueued).toEqual([]);
144
+ });
145
+ });
@@ -0,0 +1,37 @@
1
+ import type { JobQueue, Unsubscribe } from "@podosoft/podokit-runtime";
2
+ import type { EventEnvelope } from "../events/event-envelope";
3
+ import {
4
+ PROGRESS_JOB,
5
+ processProgress,
6
+ progressEvent,
7
+ progressPayload,
8
+ } from "./progress.processor";
9
+
10
+ export class ProgressWorker {
11
+ private unsubscribe?: Unsubscribe;
12
+
13
+ constructor(
14
+ private readonly queue: JobQueue,
15
+ private readonly publish: (event: EventEnvelope) => Promise<void>,
16
+ private readonly delay?: (milliseconds: number) => Promise<unknown>,
17
+ ) {}
18
+
19
+ async connect(): Promise<void> {
20
+ this.unsubscribe = await this.queue.process(PROGRESS_JOB, async (payload, job) => {
21
+ // Jobs enqueued before progress had owners run without publishing,
22
+ // because there is nobody they may be shown to.
23
+ const ownerId = progressPayload(payload).ownerId;
24
+ await processProgress(
25
+ payload,
26
+ async (progress) => {
27
+ if (ownerId) await this.publish(progressEvent(job.id, ownerId, progress));
28
+ },
29
+ this.delay,
30
+ );
31
+ });
32
+ }
33
+
34
+ async close(): Promise<void> {
35
+ await this.unsubscribe?.();
36
+ }
37
+ }
@@ -1,17 +1,78 @@
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 origin = { origin: base };
5
5
 
6
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
6
+ type Playwright = PlaywrightWorkerArgs["playwright"];
7
+
8
+ async function session(playwright: Playwright): Promise<APIRequestContext> {
7
9
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
10
  const email = `progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
11
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "P" } }).catch(() => undefined);
10
12
  return ctx;
11
13
  }
12
14
 
13
- // The end-to-end progress stream (worker → Redis pub/sub → SSE) needs the worker
14
- // process running; this asserts the API contract (a job is enqueued).
15
+ async function signedIn(ctx: APIRequestContext): Promise<boolean> {
16
+ const response = await ctx.get("/api/auth/get-session");
17
+ if (!response.ok()) return false;
18
+ const body = await response.json() as { user?: { id?: string } } | null;
19
+ return Boolean(body?.user?.id);
20
+ }
21
+
22
+ class ProgressStream {
23
+ private readonly decoder = new TextDecoder();
24
+ private buffered = "";
25
+
26
+ private constructor(
27
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>,
28
+ private readonly controller: AbortController,
29
+ ) {}
30
+
31
+ static async open(ctx: APIRequestContext): Promise<ProgressStream> {
32
+ const controller = new AbortController();
33
+ const cookie = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
34
+ const response = await fetch(`${base}/api/events/stream?topics=job-progress`, {
35
+ headers: { ...origin, cookie },
36
+ signal: controller.signal,
37
+ });
38
+ expect(response.status).toBe(200);
39
+ const reader = response.body?.getReader();
40
+ if (!reader) throw new Error("event stream has no body");
41
+ const stream = new ProgressStream(reader, controller);
42
+ await stream.until("retry:");
43
+ return stream;
44
+ }
45
+
46
+ async until(text: string, timeoutMs = 15_000): Promise<string> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ while (!this.buffered.includes(text)) {
49
+ const remaining = deadline - Date.now();
50
+ if (remaining <= 0) throw new Error(`timed out waiting for ${text}`);
51
+ let timer: ReturnType<typeof setTimeout> | undefined;
52
+ const timeout = new Promise<"timeout">((resolve) => {
53
+ timer = setTimeout(() => resolve("timeout"), remaining);
54
+ });
55
+ const result = await Promise.race([this.reader.read(), timeout]);
56
+ clearTimeout(timer);
57
+ if (result === "timeout") continue;
58
+ if (result.done) throw new Error(`stream ended before ${text}`);
59
+ this.buffered += this.decoder.decode(result.value, { stream: true });
60
+ }
61
+ const end = this.buffered.indexOf(text) + text.length;
62
+ const seen = this.buffered.slice(0, end);
63
+ this.buffered = this.buffered.slice(end);
64
+ return seen;
65
+ }
66
+
67
+ close(): void {
68
+ this.controller.abort();
69
+ }
70
+ }
71
+
72
+ function completed(jobId: string): string {
73
+ return JSON.stringify({ topic: "job-progress", data: { jobId, progress: 100 } });
74
+ }
75
+
15
76
  test("job-progress: starting a job returns a job id @smoke", async ({ playwright }) => {
16
77
  const ctx = await session(playwright);
17
78
  const res = await ctx.post("/api/progress", { data: { steps: 3 } });
@@ -20,3 +81,32 @@ test("job-progress: starting a job returns a job id @smoke", async ({ playwright
20
81
  expect(typeof (await res.json()).jobId).toBe("string");
21
82
  await ctx.dispose();
22
83
  });
84
+
85
+ test("job-progress: progress reaches only the user who started the job", async ({ playwright }) => {
86
+ const owner = await session(playwright);
87
+ const other = await session(playwright);
88
+ const streams: ProgressStream[] = [];
89
+ try {
90
+ test.skip(!(await signedIn(owner)) || !(await signedIn(other)), "requires the auth module");
91
+ const ownerStream = await ProgressStream.open(owner);
92
+ const otherStream = await ProgressStream.open(other);
93
+ streams.push(ownerStream, otherStream);
94
+
95
+ const started = await owner.post("/api/progress", { data: { steps: 2 } });
96
+ test.skip(started.status() >= 500, "redis not reachable");
97
+ expect(started.status()).toBe(201);
98
+ const { jobId } = await started.json() as { jobId: string };
99
+ await ownerStream.until(completed(jobId));
100
+
101
+ // Every event the owner's job produced was published before the other user's
102
+ // job started, so it would precede that job's completion on the other stream.
103
+ const otherStarted = await other.post("/api/progress", { data: { steps: 1 } });
104
+ expect(otherStarted.status()).toBe(201);
105
+ const { jobId: otherJobId } = await otherStarted.json() as { jobId: string };
106
+ expect(await otherStream.until(completed(otherJobId))).not.toContain(JSON.stringify(jobId));
107
+ } finally {
108
+ for (const stream of streams) stream.close();
109
+ await owner.dispose();
110
+ await other.dispose();
111
+ }
112
+ });
@@ -12,8 +12,8 @@
12
12
  ],
13
13
  "instructions": [
14
14
  "Configure the selected jobs and events providers before starting the API.",
15
- "Watch progress: curl -N localhost:5002/events/stream",
16
- "Start a job: curl -XPOST localhost:5002/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
17
- "The stream shows job-progress events 20 -> 40 -> ... -> 100 through the selected events provider."
15
+ "Watch progress as a signed-in user: curl -N -b cookies.txt 'localhost:5002/events/stream?topics=job-progress'",
16
+ "Start a job as the same user: curl -b cookies.txt -XPOST localhost:5002/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
17
+ "The stream shows job-progress events 20 -> 40 -> ... -> 100, delivered only to the user who started the job."
18
18
  ]
19
19
  }
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { SmtpConfig } from "@podosoft/podokit-auth";
3
+ import type { Transporter } from "nodemailer";
4
+ import { mailTransport, setMailConfigStore, setMailTransport } from "./mailer";
5
+
6
+ function storeOf(read: () => SmtpConfig | null) {
7
+ let calls = 0;
8
+ return {
9
+ store: {
10
+ smtpConfig: async (): Promise<SmtpConfig | null> => {
11
+ calls += 1;
12
+ return read();
13
+ },
14
+ },
15
+ calls: (): number => calls,
16
+ };
17
+ }
18
+
19
+ afterEach(() => {
20
+ setMailTransport(null);
21
+ });
22
+
23
+ describe("mailer transport", () => {
24
+ test("reuses the transport while the SMTP settings are unchanged", async () => {
25
+ let smtp: SmtpConfig | null = { host: "smtp.example.com", port: 587, secure: false, user: "u", pass: "p" };
26
+ const { store, calls } = storeOf(() => smtp);
27
+ setMailConfigStore(store);
28
+
29
+ const first = await mailTransport();
30
+ expect(await mailTransport()).toBe(first);
31
+ smtp = { ...smtp };
32
+ expect(await mailTransport()).toBe(first);
33
+ expect(calls()).toBe(3);
34
+
35
+ smtp = { ...smtp, host: "smtp2.example.com" };
36
+ const second = await mailTransport();
37
+ expect(second).not.toBe(first);
38
+ expect(await mailTransport()).toBe(second);
39
+ });
40
+
41
+ test("falls back to the environment when the store fails, and honours an override", async () => {
42
+ setMailConfigStore({
43
+ smtpConfig: async () => {
44
+ throw new Error('relation "auth_config" does not exist');
45
+ },
46
+ });
47
+ const fallback = await mailTransport();
48
+ expect(fallback).toBeDefined();
49
+
50
+ const custom = { sendMail: async () => ({}) } as unknown as Transporter;
51
+ setMailTransport(custom);
52
+ expect(await mailTransport()).toBe(custom);
53
+ });
54
+ });
@@ -1,17 +1,18 @@
1
1
  import nodemailer, { type Transporter } from "nodemailer";
2
2
  import { createConfigStore, type SmtpConfig } from "@podosoft/podokit-auth";
3
3
 
4
- // Reusable email sending. SMTP is resolved in priority order, applied live (short
5
- // TTL) so no restart is needed:
4
+ // Reusable email sending. SMTP is resolved in priority order, applied live so no
5
+ // restart is needed:
6
6
  // 1. admin-configured DB settings (auth_config) — only when the auth module is
7
- // installed; read via the config store, which decrypts the SMTP password;
7
+ // installed; read from the shared config snapshot, which decrypts the SMTP
8
+ // password once per change and follows the snapshot TTL;
8
9
  // 2. SMTP_* env (fallback / production secrets-manager injection);
9
10
  // 3. none → a JSON transport that logs the message so dev links are grabbable.
10
11
  // The whole transport can also be overridden from app code (setMailTransport),
11
12
  // e.g. from apps/api/src/app.extensions.ts, to plug in a provider SDK.
12
13
  type MailConfigStore = Pick<ReturnType<typeof createConfigStore>, "smtpConfig">;
13
14
  let store: MailConfigStore | undefined;
14
- const TTL_MS = 3_000;
15
+ const DEFAULT_FROM = "PodoKit <no-reply@example.com>";
15
16
 
16
17
  export function setMailConfigStore(configStore: MailConfigStore): void {
17
18
  store = configStore;
@@ -49,11 +50,19 @@ function buildTransport(smtp: SmtpConfig | null): Transporter {
49
50
  });
50
51
  }
51
52
 
52
- let transport = buildTransport(null);
53
- let transportFrom = process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
54
- let hasSmtp = false;
55
- let checkedAt = 0;
56
- let signature = "";
53
+ type MailSetup = {
54
+ smtp: SmtpConfig | null;
55
+ signature: string;
56
+ transport: Transporter;
57
+ from: string;
58
+ };
59
+
60
+ let setup: MailSetup = {
61
+ smtp: null,
62
+ signature: "null",
63
+ transport: buildTransport(null),
64
+ from: process.env.MAIL_FROM ?? DEFAULT_FROM,
65
+ };
57
66
  let override: Transporter | null = null;
58
67
 
59
68
  /** Override the transport from app code (e.g. a provider SDK). Pass null to
@@ -62,31 +71,44 @@ export function setMailTransport(custom: Transporter | null): void {
62
71
  override = custom;
63
72
  }
64
73
 
65
- async function getTransport(): Promise<Transporter> {
66
- if (override) return override;
67
- if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return transport;
68
- checkedAt = Date.now();
74
+ /** The config-driven setup. The store returns the same SMTP object until the
75
+ * config changes, and the transport is rebuilt only when its settings differ. */
76
+ async function currentSetup(): Promise<MailSetup> {
77
+ const smtp = await resolveSmtp();
78
+ if (smtp === setup.smtp) return setup;
79
+ const signature = JSON.stringify(smtp);
80
+ if (signature === setup.signature) {
81
+ setup = { ...setup, smtp };
82
+ return setup;
83
+ }
69
84
  try {
70
- const smtp = await resolveSmtp();
71
- const next = JSON.stringify(smtp ?? null);
72
- if (next !== signature) {
73
- transport = buildTransport(smtp);
74
- transportFrom = smtp?.from ?? process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
75
- hasSmtp = !!smtp;
76
- signature = next;
77
- }
85
+ setup = {
86
+ smtp,
87
+ signature,
88
+ transport: buildTransport(smtp),
89
+ from: smtp?.from ?? process.env.MAIL_FROM ?? DEFAULT_FROM,
90
+ };
78
91
  } catch {
79
92
  /* keep the last-good transport */
80
93
  }
81
- return transport;
94
+ return setup;
95
+ }
96
+
97
+ /** The transport `sendMail` uses now: the override, or the one built from SMTP config. */
98
+ export async function mailTransport(): Promise<Transporter> {
99
+ return override ?? (await currentSetup()).transport;
82
100
  }
83
101
 
84
102
  export type Mail = { to: string; subject: string; text: string; html?: string };
85
103
 
86
104
  export async function sendMail(mail: Mail): Promise<void> {
87
- const t = await getTransport();
88
- const info = await t.sendMail({ from: transportFrom, ...mail });
89
- if (!override && !hasSmtp) {
105
+ if (override) {
106
+ await override.sendMail({ from: setup.from, ...mail });
107
+ return;
108
+ }
109
+ const current = await currentSetup();
110
+ const info = await current.transport.sendMail({ from: current.from, ...mail });
111
+ if (!current.smtp) {
90
112
  // JSON transport: surface the message so the link is grabbable from logs.
91
113
  console.log(`[mailer] no SMTP configured; message not delivered:\n${info.message as string}`);
92
114
  }
@@ -5,7 +5,7 @@
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "nodemailer": "^9.0.5",
8
- "@podosoft/podokit-auth": "^0.4.0"
8
+ "@podosoft/podokit-auth": "^0.5.0"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/nodemailer": "^8.0.1"
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { LocalObjectStore } from "@podosoft/podokit-runtime";
6
+ import { DownloadUrlSigner, objectKeyFromPath, SignedDownloadStore } from "./download-url";
7
+
8
+ let root: string | undefined;
9
+
10
+ async function tempRoot(): Promise<string> {
11
+ root = await mkdtemp(join(tmpdir(), "podokit-download-url-"));
12
+ return root;
13
+ }
14
+
15
+ afterEach(async () => {
16
+ if (root) await rm(root, { recursive: true, force: true });
17
+ root = undefined;
18
+ });
19
+
20
+ describe("DownloadUrlSigner", () => {
21
+ it("accepts only the signed key before it expires", async () => {
22
+ const keyFile = join(await tempRoot(), ".podokit", "download-url.key");
23
+ const now = 1_700_000_000_000;
24
+ const signer = new DownloadUrlSigner(keyFile, () => now);
25
+ const { expires, signature } = await signer.sign("uploads/a/file.txt", 60);
26
+
27
+ expect(expires).toBe(String(now / 1000 + 60));
28
+ expect(await signer.verify("uploads/a/file.txt", expires, signature)).toBe(true);
29
+ expect(await signer.verify("uploads/b/file.txt", expires, signature)).toBe(false);
30
+ expect(await signer.verify("uploads/a/file.txt", String(Number(expires) + 1), signature)).toBe(false);
31
+ expect(await signer.verify("uploads/a/file.txt", expires, `${signature.slice(0, -2)}AA`)).toBe(false);
32
+ expect(await signer.verify("uploads/a/file.txt", undefined, signature)).toBe(false);
33
+ expect(await signer.verify("uploads/a/file.txt", expires, undefined)).toBe(false);
34
+ expect(await signer.verify("uploads/a/file.txt", "1e9", signature)).toBe(false);
35
+
36
+ const later = new DownloadUrlSigner(keyFile, () => now + 61_000);
37
+ expect(await later.verify("uploads/a/file.txt", expires, signature)).toBe(false);
38
+ });
39
+
40
+ it("persists one private key for every signer that shares the key file", async () => {
41
+ const keyFile = join(await tempRoot(), ".podokit", "download-url.key");
42
+ const [first, second] = [new DownloadUrlSigner(keyFile), new DownloadUrlSigner(keyFile)];
43
+ const [signed] = await Promise.all([first.sign("demo/hello"), second.sign("demo/other")]);
44
+
45
+ expect(await second.verify("demo/hello", signed.expires, signed.signature)).toBe(true);
46
+ expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
47
+ });
48
+
49
+ it("caps the lifetime at seven days", async () => {
50
+ const keyFile = join(await tempRoot(), "download-url.key");
51
+ const signer = new DownloadUrlSigner(keyFile, () => 0);
52
+ const { expires } = await signer.sign("demo/hello", 365 * 24 * 60 * 60);
53
+ expect(Number(expires)).toBe(7 * 24 * 60 * 60);
54
+ });
55
+ });
56
+
57
+ describe("objectKeyFromPath", () => {
58
+ it("decodes each path segment and rejects malformed encodings", () => {
59
+ expect(objectKeyFromPath("uploads/user%20one/a.txt")).toBe("uploads/user one/a.txt");
60
+ expect(objectKeyFromPath("uploads/%E0%A4%A")).toBeUndefined();
61
+ });
62
+ });
63
+
64
+ describe("SignedDownloadStore", () => {
65
+ it("adds a verifiable signature to local download URLs", async () => {
66
+ const directory = await tempRoot();
67
+ const signer = new DownloadUrlSigner(join(directory, ".podokit", "download-url.key"));
68
+ const store = new SignedDownloadStore(
69
+ new LocalObjectStore({ rootDirectory: directory, publicBaseUrl: "/files/content" }),
70
+ signer,
71
+ "/files/content",
72
+ );
73
+ await store.put("uploads/user one.txt", "hello", { contentType: "text/plain" });
74
+
75
+ const url = new URL(await store.getDownloadUrl("uploads/user one.txt"), "http://localhost");
76
+ expect(url.pathname).toBe("/files/content/uploads/user%20one.txt");
77
+ expect(
78
+ await signer.verify(
79
+ "uploads/user one.txt",
80
+ url.searchParams.get("expires") ?? undefined,
81
+ url.searchParams.get("signature") ?? undefined,
82
+ ),
83
+ ).toBe(true);
84
+ await expect(store.getDownloadUrl("../outside.txt")).rejects.toThrow("safe relative path");
85
+ });
86
+ });