@podosoft/podokit 1.1.6 → 1.2.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 (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -1,87 +1,198 @@
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)}`;
52
+ }
53
+
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
+ }
12
104
  }
13
105
 
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();
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
+ }
20
120
  });
21
121
 
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;
122
+ test("sse: only administrators may publish", async ({ playwright }) => {
123
+ const member = await newContext(playwright);
124
+ try {
125
+ test.skip(!(await signUp(member)), "requires the auth module");
126
+ const published = await member.post("/api/events", { data: { message: "hello" } });
127
+ expect(published.status()).toBe(403);
128
+ expect((await published.json()).error.code).toBe("ADMIN_REQUIRED");
129
+ } finally {
130
+ await member.dispose();
131
+ }
132
+ });
133
+
134
+ test("sse: an admin publish reaches only the addressed user @smoke", async ({ playwright }) => {
135
+ const adminCtx = await newContext(playwright);
136
+ const aliceCtx = await newContext(playwright);
137
+ const bobCtx = await newContext(playwright);
138
+ const streams: EventStreamReader[] = [];
139
+ try {
140
+ test.skip(!(await signInAdmin(adminCtx)), "requires the auth module and the seeded administrator");
141
+ const alice = await signUp(aliceCtx);
142
+ expect(alice).toBeDefined();
143
+ expect(await signUp(bobCtx)).toBeDefined();
144
+ const topic = uniqueTopic();
145
+ const aliceStream = await EventStreamReader.open(aliceCtx, topic);
146
+ const bobStream = await EventStreamReader.open(bobCtx, topic);
147
+ streams.push(aliceStream, bobStream);
148
+
149
+ const secret = `secret-${Date.now()}`;
150
+ const done = `done-${Date.now()}`;
151
+ const targeted = await adminCtx.post("/api/events", {
152
+ data: { topic, audience: { userIds: [alice?.id] }, message: secret },
153
+ });
154
+ expect(targeted.status()).toBe(201);
155
+ expect(await targeted.json()).toEqual({ ok: true });
156
+ expect((await adminCtx.post("/api/events", { data: { topic, message: done } })).status()).toBe(201);
157
+
158
+ const aliceSaw = await aliceStream.until(done);
159
+ expect(aliceSaw).toContain(JSON.stringify({ topic, data: { message: secret } }));
160
+ expect(await bobStream.until(done)).not.toContain(secret);
161
+ } finally {
162
+ for (const stream of streams) stream.close();
163
+ await adminCtx.dispose();
164
+ await aliceCtx.dispose();
165
+ await bobCtx.dispose();
43
166
  }
44
- ac.abort();
45
- expect(seen).toBe(true);
46
- await ctx.dispose();
47
167
  });
48
168
 
49
169
  test("sse: a second API replica delivers through Redis", async ({ playwright }) => {
50
170
  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;
171
+ const adminCtx = await newContext(playwright);
172
+ const memberCtx = await newContext(playwright);
173
+ let stream: EventStreamReader | undefined;
174
+ try {
175
+ test.skip(!(await signInAdmin(adminCtx)), "requires the auth module and the seeded administrator");
176
+ const member = await signUp(memberCtx);
177
+ expect(member).toBeDefined();
178
+ const topic = uniqueTopic();
179
+ stream = await EventStreamReader.open(memberCtx, topic);
180
+
181
+ const message = `replica-${Date.now()}`;
182
+ const published = await fetch(`${secondaryApi}/events`, {
183
+ method: "POST",
184
+ headers: { ...origin, cookie: await cookieHeader(adminCtx), "content-type": "application/json" },
185
+ body: JSON.stringify({ topic, audience: { userIds: [member?.id] }, message }),
186
+ });
187
+ expect(published.status).toBe(201);
188
+
189
+ const done = `done-${Date.now()}`;
190
+ expect((await adminCtx.post("/api/events", { data: { topic, message: done } })).status()).toBe(201);
191
+ const seen = await stream.until(done);
192
+ expect(seen.split(message).length - 1).toBe(1);
193
+ } finally {
194
+ stream?.close();
195
+ await adminCtx.dispose();
196
+ await memberCtx.dispose();
83
197
  }
84
- ac.abort();
85
- expect(seen).toBe(1);
86
- await ctx.dispose();
87
198
  });
@@ -6,6 +6,14 @@
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",
@@ -16,9 +24,14 @@
16
24
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "eventsModule," }
17
25
  ],
18
26
  "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."
27
+ "Stream as a signed-in user: curl -N -b cookies.txt 'localhost:5002/events/stream?topics=message'",
28
+ "Publish as an administrator (in another terminal): curl -b admin-cookies.txt -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
29
+ "Each data event is a default SSE message whose data is {\"topic\",\"data\"}. A resync event means events were dropped, so reload state.",
30
+ "Publish from a module with EventsService.publish({ topic, audience, data }). An audience of userIds, roles, or organizationId limits who receives it.",
31
+ "Use publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica.",
32
+ "Restrict who may subscribe to a topic with events.topics.register(topic, authorizer).",
33
+ "Streams take the subscriber's roles and active organization from the request identity. Register EVENT_IDENTITY in app.extensions.ts to derive them differently.",
34
+ "In the browser, subscribe(topic, handler) from $lib/events shares one EventSource per tab.",
35
+ "Select events=redis for multiple replicas or events=memory for a single process."
23
36
  ]
24
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.1.6",
3
+ "version": "1.2.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
  }