@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
@@ -0,0 +1,191 @@
1
+ import type { EventSubscriber } from "./event-envelope";
2
+
3
+ export type StreamFrame =
4
+ | { kind: "event"; id: string; data: string }
5
+ | { kind: "resync" }
6
+ | { kind: "heartbeat" };
7
+
8
+ type EventFrame = Extract<StreamFrame, { kind: "event" }>;
9
+
10
+ const RESYNC: StreamFrame = { kind: "resync" };
11
+ const HEARTBEAT: StreamFrame = { kind: "heartbeat" };
12
+
13
+ export interface Scheduler {
14
+ /** Runs `task` every `intervalMs` until the returned function is called. */
15
+ every(intervalMs: number, task: () => void): () => void;
16
+ }
17
+
18
+ export const timerScheduler: Scheduler = {
19
+ every: (intervalMs, task) => {
20
+ const timer = setInterval(task, intervalMs);
21
+ timer.unref();
22
+ return () => clearInterval(timer);
23
+ },
24
+ };
25
+
26
+ export interface EventStreamOptions {
27
+ queueLimit: number;
28
+ heartbeatMs: number;
29
+ maxConnectionMs: number;
30
+ scheduler: Scheduler;
31
+ now: () => number;
32
+ }
33
+
34
+ /**
35
+ * One subscriber connection. Frames are pulled by the response body, so a slow
36
+ * client fills the bounded queue; on overflow the backlog is dropped and the
37
+ * client receives a single `resync` frame telling it to refetch state.
38
+ */
39
+ export class EventStream {
40
+ private readonly queue: EventFrame[] = [];
41
+ private readonly openedAt: number;
42
+ private readonly onAbort = (): void => this.close();
43
+ private resyncPending = false;
44
+ private heartbeatPending = false;
45
+ private sequence = 0;
46
+ private closed = false;
47
+ private waiter?: () => void;
48
+ private stopHeartbeat?: () => void;
49
+ private signal?: AbortSignal;
50
+
51
+ constructor(
52
+ readonly subscriber: EventSubscriber,
53
+ readonly topics: ReadonlySet<string> | undefined,
54
+ private readonly options: EventStreamOptions,
55
+ private readonly onClose: (stream: EventStream) => void,
56
+ ) {
57
+ this.openedAt = options.now();
58
+ }
59
+
60
+ get isClosed(): boolean {
61
+ return this.closed;
62
+ }
63
+
64
+ /** Binds the stream to its request. An already aborted signal closes it at once. */
65
+ start(signal: AbortSignal): void {
66
+ if (this.closed) return;
67
+ if (signal.aborted) {
68
+ this.close();
69
+ return;
70
+ }
71
+ this.signal = signal;
72
+ signal.addEventListener("abort", this.onAbort, { once: true });
73
+ this.stopHeartbeat = this.options.scheduler.every(this.options.heartbeatMs, () => this.tick());
74
+ }
75
+
76
+ offer(data: string, id: string | undefined): void {
77
+ if (this.closed) return;
78
+ if (this.queue.length >= this.options.queueLimit) {
79
+ this.queue.length = 0;
80
+ this.resyncPending = true;
81
+ } else {
82
+ this.queue.push({ kind: "event", id: id ?? String(++this.sequence), data });
83
+ }
84
+ this.wake();
85
+ }
86
+
87
+ /** Resolves the next frame, or undefined once the stream is closed. Single consumer. */
88
+ async next(): Promise<StreamFrame | undefined> {
89
+ while (!this.closed) {
90
+ const frame = this.take();
91
+ if (frame) return frame;
92
+ await new Promise<void>((resolve) => {
93
+ this.waiter = resolve;
94
+ });
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ close(): void {
100
+ if (this.closed) return;
101
+ this.closed = true;
102
+ this.queue.length = 0;
103
+ this.stopHeartbeat?.();
104
+ this.stopHeartbeat = undefined;
105
+ this.signal?.removeEventListener("abort", this.onAbort);
106
+ this.signal = undefined;
107
+ this.onClose(this);
108
+ this.wake();
109
+ }
110
+
111
+ private take(): StreamFrame | undefined {
112
+ if (this.resyncPending) {
113
+ this.resyncPending = false;
114
+ return RESYNC;
115
+ }
116
+ const event = this.queue.shift();
117
+ if (event) {
118
+ this.heartbeatPending = false;
119
+ return event;
120
+ }
121
+ if (this.heartbeatPending) {
122
+ this.heartbeatPending = false;
123
+ return HEARTBEAT;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ private tick(): void {
129
+ // The identity was resolved at connect. Ending long-lived streams makes the
130
+ // client reconnect and re-authenticate, so revoked sessions and roles lapse.
131
+ if (this.options.now() - this.openedAt >= this.options.maxConnectionMs) {
132
+ this.close();
133
+ return;
134
+ }
135
+ this.heartbeatPending = true;
136
+ this.wake();
137
+ }
138
+
139
+ private wake(): void {
140
+ const waiter = this.waiter;
141
+ this.waiter = undefined;
142
+ waiter?.();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Data events use the default `message` type with `{ topic, data }` as JSON, so
148
+ * `EventSource.onmessage` keeps working. Control frames use a named event or a
149
+ * comment and never reach `onmessage`.
150
+ */
151
+ export function encodeFrame(frame: StreamFrame): string {
152
+ switch (frame.kind) {
153
+ case "event":
154
+ return `id: ${frame.id}\ndata: ${frame.data}\n\n`;
155
+ case "resync":
156
+ return "event: resync\ndata: {}\n\n";
157
+ case "heartbeat":
158
+ return ": heartbeat\n\n";
159
+ }
160
+ }
161
+
162
+ export function eventStreamResponse(stream: EventStream, retryMs: number): Response {
163
+ const encoder = new TextEncoder();
164
+ const body = new ReadableStream<Uint8Array>({
165
+ start(controller): void {
166
+ controller.enqueue(encoder.encode(`retry: ${retryMs}\n\n`));
167
+ },
168
+ async pull(controller): Promise<void> {
169
+ const frame = await stream.next();
170
+ if (frame) {
171
+ controller.enqueue(encoder.encode(encodeFrame(frame)));
172
+ return;
173
+ }
174
+ try {
175
+ controller.close();
176
+ } catch {
177
+ // The body was cancelled while this pull was waiting.
178
+ }
179
+ },
180
+ cancel(): void {
181
+ stream.close();
182
+ },
183
+ });
184
+ return new Response(body, {
185
+ headers: {
186
+ "content-type": "text/event-stream; charset=utf-8",
187
+ "cache-control": "no-cache, no-transform",
188
+ "x-accel-buffering": "no",
189
+ },
190
+ });
191
+ }
@@ -0,0 +1,64 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { EVENT_NAME_PATTERN, isEventName, type EventSubscriber } from "./event-envelope";
3
+
4
+ export const MAX_STREAM_TOPICS = 32;
5
+
6
+ export type TopicAuthorizer = (
7
+ subscriber: EventSubscriber,
8
+ topic: string,
9
+ ) => boolean | Promise<boolean>;
10
+
11
+ interface PrefixAuthorizer {
12
+ prefix: string;
13
+ authorize: TopicAuthorizer;
14
+ }
15
+
16
+ /**
17
+ * Restricts who may subscribe to a topic. A topic without an authorizer is open to
18
+ * every signed-in user. A pattern ending in `*` guards every topic with that prefix,
19
+ * and every authorizer that matches a topic must allow the subscriber.
20
+ */
21
+ export class TopicAuthorizers {
22
+ private readonly exact = new Map<string, TopicAuthorizer[]>();
23
+ private readonly prefixes: PrefixAuthorizer[] = [];
24
+
25
+ register(pattern: string, authorize: TopicAuthorizer): void {
26
+ if (pattern.endsWith("*")) {
27
+ const prefix = pattern.slice(0, -1);
28
+ if (!isEventName(prefix)) throw new TypeError(`Topic prefix must match ${EVENT_NAME_PATTERN}`);
29
+ this.prefixes.push({ prefix, authorize });
30
+ return;
31
+ }
32
+ if (!isEventName(pattern)) throw new TypeError(`Topic must match ${EVENT_NAME_PATTERN}`);
33
+ this.exact.set(pattern, [...(this.exact.get(pattern) ?? []), authorize]);
34
+ }
35
+
36
+ guarded(topic: string): boolean {
37
+ return this.exact.has(topic) || this.prefixes.some(({ prefix }) => topic.startsWith(prefix));
38
+ }
39
+
40
+ async allows(subscriber: EventSubscriber, topic: string): Promise<boolean> {
41
+ const authorizers = [
42
+ ...(this.exact.get(topic) ?? []),
43
+ ...this.prefixes.filter(({ prefix }) => topic.startsWith(prefix)).map(({ authorize }) => authorize),
44
+ ];
45
+ for (const authorize of authorizers) {
46
+ if (!(await authorize(subscriber, topic))) return false;
47
+ }
48
+ return true;
49
+ }
50
+ }
51
+
52
+ /** Parses `?topics=a,b`. Undefined means every topic that has no authorizer. */
53
+ export function parseTopics(value: string | undefined): string[] | undefined {
54
+ if (value === undefined) return undefined;
55
+ const topics = [...new Set(value.split(",").map((topic) => topic.trim()).filter(Boolean))];
56
+ if (topics.length === 0 || topics.length > MAX_STREAM_TOPICS || !topics.every(isEventName)) {
57
+ throw new AppException(
58
+ "EVENTS_TOPICS_INVALID",
59
+ `topics must list 1 to ${MAX_STREAM_TOPICS} comma-separated names matching ${EVENT_NAME_PATTERN}`,
60
+ 400,
61
+ );
62
+ }
63
+ return topics;
64
+ }
@@ -0,0 +1,203 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import { MemoryEventBus } from "@podosoft/podokit-runtime";
4
+ import { Elysia } from "elysia";
5
+ import type { RequestIdentity } from "../core/services";
6
+ import { ReadinessService } from "../health/readiness.service";
7
+ import { eventsRoutes, requestEventIdentity, type EventIdentity } from "./events.module";
8
+ import { EventsService } from "./events.service";
9
+
10
+ /** Maps the `x-user`, `x-admin`, `x-roles`, and `x-org` test headers to the request identity. */
11
+ const headerIdentity: RequestIdentity = {
12
+ userId: async (request) => request.headers.get("x-user") ?? undefined,
13
+ isAdmin: async (request) => request.headers.get("x-admin") === "true",
14
+ roles: async (request) => (request.headers.get("x-roles") ?? "").split(",").filter(Boolean),
15
+ activeOrganizationId: async (request) => request.headers.get("x-org") ?? undefined,
16
+ };
17
+
18
+ const opened: Array<{ events: EventsService; controllers: AbortController[] }> = [];
19
+
20
+ async function setup(eventIdentity?: EventIdentity): Promise<{
21
+ events: EventsService;
22
+ send: (path: string, init?: RequestInit) => Promise<Response>;
23
+ }> {
24
+ const events = new EventsService(new MemoryEventBus(), new ReadinessService());
25
+ await events.connect();
26
+ const controllers: AbortController[] = [];
27
+ opened.push({ events, controllers });
28
+ const app = new Elysia()
29
+ .onError(({ code, error, set }) => {
30
+ if (code === "VALIDATION") {
31
+ set.status = 400;
32
+ return { code: "VALIDATION_ERROR" };
33
+ }
34
+ if (!(error instanceof AppException)) return undefined;
35
+ set.status = error.statusCode;
36
+ return { code: error.code };
37
+ })
38
+ .use(eventsRoutes(events, headerIdentity, eventIdentity));
39
+ return {
40
+ events,
41
+ send: (path, init = {}) => {
42
+ const controller = new AbortController();
43
+ controllers.push(controller);
44
+ return app.handle(new Request(`http://localhost${path}`, { ...init, signal: controller.signal }));
45
+ },
46
+ };
47
+ }
48
+
49
+ function publish(
50
+ send: (path: string, init?: RequestInit) => Promise<Response>,
51
+ headers: Record<string, string>,
52
+ body: Record<string, unknown>,
53
+ ): Promise<Response> {
54
+ return send("/events", {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json", ...headers },
57
+ body: JSON.stringify(body),
58
+ });
59
+ }
60
+
61
+ class SseReader {
62
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>;
63
+ private readonly decoder = new TextDecoder();
64
+ private buffered = "";
65
+
66
+ constructor(response: Response) {
67
+ const reader = response.body?.getReader();
68
+ if (!reader) throw new Error("missing body");
69
+ this.reader = reader;
70
+ }
71
+
72
+ /** Reads until `text` arrives and returns everything received up to it. */
73
+ async until(text: string): Promise<string> {
74
+ while (!this.buffered.includes(text)) {
75
+ const { value, done } = await this.reader.read();
76
+ if (done) throw new Error(`stream ended before ${text}`);
77
+ this.buffered += this.decoder.decode(value, { stream: true });
78
+ }
79
+ const end = this.buffered.indexOf(text) + text.length;
80
+ const seen = this.buffered.slice(0, end);
81
+ this.buffered = this.buffered.slice(end);
82
+ return seen;
83
+ }
84
+ }
85
+
86
+ afterEach(async () => {
87
+ for (const { events, controllers } of opened.splice(0)) {
88
+ for (const controller of controllers) controller.abort();
89
+ await events.close();
90
+ }
91
+ });
92
+
93
+ describe("events routes", () => {
94
+ it("requires a signed-in user to stream", async () => {
95
+ const { send } = await setup();
96
+ const response = await send("/events/stream");
97
+ expect(response.status).toBe(401);
98
+ expect(await response.json()).toEqual({ code: "AUTH_REQUIRED" });
99
+ });
100
+
101
+ it("rejects malformed topic lists", async () => {
102
+ const { send } = await setup();
103
+ for (const topics of ["", "a b", Array.from({ length: 33 }, (_, i) => `t${i}`).join(",")]) {
104
+ const response = await send(`/events/stream?topics=${encodeURIComponent(topics)}`, {
105
+ headers: { "x-user": "alice" },
106
+ });
107
+ expect(response.status).toBe(400);
108
+ expect(await response.json()).toEqual({ code: "EVENTS_TOPICS_INVALID" });
109
+ }
110
+ });
111
+
112
+ it("refuses a guarded topic the user may not read", async () => {
113
+ const { events, send } = await setup();
114
+ events.topics.register("ops", (subscriber) => subscriber.roles.includes("admin"));
115
+ const denied = await send("/events/stream?topics=ops", { headers: { "x-user": "alice" } });
116
+ expect(denied.status).toBe(403);
117
+ expect(await denied.json()).toEqual({ code: "EVENTS_TOPIC_FORBIDDEN" });
118
+ const allowed = await send("/events/stream?topics=ops", {
119
+ headers: { "x-user": "root", "x-admin": "true", "x-roles": "admin" },
120
+ });
121
+ expect(allowed.status).toBe(200);
122
+ expect(allowed.headers.get("content-type")).toContain("text/event-stream");
123
+ });
124
+
125
+ it("lets only administrators publish", async () => {
126
+ const { send } = await setup();
127
+ const anonymous = await publish(send, {}, { message: "hi" });
128
+ expect(anonymous.status).toBe(401);
129
+ const member = await publish(send, { "x-user": "alice" }, { message: "hi" });
130
+ expect(member.status).toBe(403);
131
+ expect(await member.json()).toEqual({ code: "ADMIN_REQUIRED" });
132
+ const admin = await publish(send, { "x-user": "root", "x-admin": "true" }, { message: "hi" });
133
+ expect(admin.status).toBe(201);
134
+ expect(await admin.json()).toEqual({ ok: true });
135
+ });
136
+
137
+ it("rejects an empty or unknown audience field", async () => {
138
+ const { send } = await setup();
139
+ const admin = { "x-user": "root", "x-admin": "true" };
140
+ expect((await publish(send, admin, { message: "x", audience: {} })).status).toBe(400);
141
+ expect((await publish(send, admin, { message: "x", audience: { team: "a" } })).status).toBe(400);
142
+ expect((await publish(send, admin, { message: "x", topic: "bad topic" })).status).toBe(400);
143
+ });
144
+
145
+ it("delivers an admin publish only to the addressed user", async () => {
146
+ const { send } = await setup();
147
+ const alice = new SseReader(await send("/events/stream?topics=notes", { headers: { "x-user": "alice" } }));
148
+ const bob = new SseReader(await send("/events/stream?topics=notes", { headers: { "x-user": "bob" } }));
149
+ expect(await alice.until("\n\n")).toBe("retry: 3000\n\n");
150
+ expect(await bob.until("\n\n")).toBe("retry: 3000\n\n");
151
+ const admin = { "x-user": "root", "x-admin": "true" };
152
+
153
+ await publish(send, admin, { topic: "notes", audience: { userIds: ["alice"] }, message: "secret" });
154
+ await publish(send, admin, { topic: "notes", message: "everyone" });
155
+
156
+ expect(await alice.until("everyone\"}}\n\n")).toBe(
157
+ 'id: 1\ndata: {"topic":"notes","data":{"message":"secret"}}\n\n' +
158
+ 'id: 2\ndata: {"topic":"notes","data":{"message":"everyone"}}\n\n',
159
+ );
160
+ expect(await bob.until("everyone")).not.toContain("secret");
161
+ });
162
+
163
+ it("matches roles and organizations from a registered event identity", async () => {
164
+ const identity: EventIdentity = {
165
+ subscriber: async (request) => {
166
+ const userId = request.headers.get("x-user");
167
+ if (!userId) return undefined;
168
+ return { userId, roles: ["member"], organizationId: request.headers.get("x-org") ?? undefined };
169
+ },
170
+ };
171
+ const { send } = await setup(identity);
172
+ const inside = new SseReader(await send("/events/stream?topics=org", {
173
+ headers: { "x-user": "a", "x-org": "o1" },
174
+ }));
175
+ const outside = new SseReader(await send("/events/stream?topics=org", {
176
+ headers: { "x-user": "b", "x-org": "o2" },
177
+ }));
178
+ const admin = { "x-user": "root", "x-admin": "true" };
179
+
180
+ await publish(send, admin, {
181
+ topic: "org",
182
+ audience: { organizationId: "o1", roles: ["member"] },
183
+ message: "o1 only",
184
+ });
185
+ await publish(send, admin, { topic: "org", message: "done" });
186
+
187
+ expect(await inside.until("done")).toContain("o1 only");
188
+ expect(await outside.until("done")).not.toContain("o1 only");
189
+ });
190
+ });
191
+
192
+ describe("default event identity", () => {
193
+ it("carries the request roles and active organization", async () => {
194
+ const identity = requestEventIdentity(headerIdentity);
195
+ const request = new Request("http://localhost/events/stream", {
196
+ headers: { "x-user": "u1", "x-roles": "admin,editor", "x-org": "org-1" },
197
+ });
198
+ expect(await identity.subscriber(request)).toEqual({ userId: "u1", roles: ["admin", "editor"], organizationId: "org-1" });
199
+ expect(await identity.subscriber(new Request("http://localhost/events/stream", { headers: { "x-user": "u2" } })))
200
+ .toEqual({ userId: "u2", roles: [] });
201
+ expect(await identity.subscriber(new Request("http://localhost/events/stream"))).toBeUndefined();
202
+ });
203
+ });
@@ -1,27 +1,105 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
2
  import { EVENTS } from "@podosoft/podokit-runtime";
3
- import { Elysia, sse, t } from "elysia";
3
+ import { Elysia, t } from "elysia";
4
4
  import {
5
+ READINESS,
6
+ REQUEST_IDENTITY,
5
7
  type AppPlugin,
8
+ type ElysiaPlugin,
6
9
  type PodokitModule,
7
- READINESS,
10
+ type RequestIdentity,
11
+ type ServiceKey,
8
12
  } from "../core/services";
13
+ import {
14
+ EVENT_NAME_PATTERN,
15
+ InvalidEventError,
16
+ type EventEnvelope,
17
+ type EventSubscriber,
18
+ } from "./event-envelope";
19
+ import { eventStreamResponse } from "./event-stream";
20
+ import { parseTopics } from "./event-topics";
9
21
  import { EventsService } from "./events.service";
10
22
 
11
- export const SSE_EVENTS = Symbol("sse-events") as import("../core/services").ServiceKey<EventsService>;
23
+ export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
24
+
25
+ /** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
26
+ export interface EventIdentity {
27
+ subscriber(request: Request): Promise<EventSubscriber | undefined>;
28
+ }
29
+
30
+ export const EVENT_IDENTITY = Symbol("event-identity") as ServiceKey<EventIdentity>;
31
+
32
+ /** The default identity: the request user with their roles and active organization. */
33
+ export function requestEventIdentity(identity: RequestIdentity): EventIdentity {
34
+ return {
35
+ subscriber: async (request) => {
36
+ const userId = await identity.userId(request);
37
+ if (!userId) return undefined;
38
+ const [roles, organizationId] = await Promise.all([
39
+ identity.roles(request),
40
+ identity.activeOrganizationId(request),
41
+ ]);
42
+ return organizationId === undefined ? { userId, roles } : { userId, roles, organizationId };
43
+ },
44
+ };
45
+ }
12
46
 
13
- const eventsPlugin: AppPlugin = ({ services }) => {
14
- const events = services.resolve(SSE_EVENTS);
47
+ const nameSchema = t.String({ pattern: EVENT_NAME_PATTERN });
48
+ const idListSchema = t.Array(t.String({ minLength: 1, maxLength: 256 }), { maxItems: 1_000 });
49
+ const audienceSchema = t.Object(
50
+ {
51
+ userIds: t.Optional(idListSchema),
52
+ roles: t.Optional(idListSchema),
53
+ organizationId: t.Optional(t.String({ minLength: 1, maxLength: 256 })),
54
+ },
55
+ { additionalProperties: false, minProperties: 1 },
56
+ );
57
+
58
+ async function requireAdmin(identity: RequestIdentity, request: Request): Promise<void> {
59
+ if (!(await identity.userId(request))) {
60
+ throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
61
+ }
62
+ if (!(await identity.isAdmin(request))) {
63
+ throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
64
+ }
65
+ }
66
+
67
+ export function eventsRoutes(
68
+ events: EventsService,
69
+ identity: RequestIdentity,
70
+ eventIdentity: EventIdentity = requestEventIdentity(identity),
71
+ ): ElysiaPlugin {
15
72
  return new Elysia({ name: "podokit.events" })
16
- .get("/events/stream", async function* ({ request }) {
17
- for await (const data of events.stream(request.signal)) yield sse({ data });
73
+ .get("/events/stream", async ({ request, query }) => {
74
+ const subscriber = await eventIdentity.subscriber(request);
75
+ if (!subscriber) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
76
+ const stream = await events.open(subscriber, parseTopics(query.topics), request.signal);
77
+ return eventStreamResponse(stream, events.retryMs);
18
78
  }, {
19
- detail: { tags: ["events"], summary: "Stream server-sent events" },
79
+ query: t.Object({ topics: t.Optional(t.String({ maxLength: 4_096 })) }),
80
+ detail: {
81
+ tags: ["events"],
82
+ summary: "Stream server-sent events",
83
+ description:
84
+ "Signed-in users only. `topics` is a comma-separated list; without it the stream " +
85
+ "carries every topic that has no authorizer. Each data event is a default " +
86
+ "`message` whose data is `{ topic, data }`. A `resync` event means events were " +
87
+ "dropped and the client should reload its state.",
88
+ },
20
89
  })
21
- .post("/events", async ({ body, set }) => {
90
+ .post("/events", async ({ request, body, set }) => {
91
+ await requireAdmin(identity, request);
92
+ const event: EventEnvelope = {
93
+ topic: body.topic ?? "message",
94
+ data: { message: body.message },
95
+ ...(body.audience ? { audience: body.audience } : {}),
96
+ };
22
97
  try {
23
- await events.publishAsync({ type: "message", message: body.message });
24
- } catch {
98
+ await events.publishAsync(event);
99
+ } catch (error) {
100
+ if (error instanceof InvalidEventError) {
101
+ throw new AppException("EVENTS_INVALID", error.message, 400);
102
+ }
25
103
  throw new AppException(
26
104
  "EVENTS_TRANSPORT_UNAVAILABLE",
27
105
  "Event transport is unavailable",
@@ -31,10 +109,25 @@ const eventsPlugin: AppPlugin = ({ services }) => {
31
109
  set.status = 201;
32
110
  return { ok: true as const };
33
111
  }, {
34
- body: t.Object({ message: t.String() }),
35
- detail: { tags: ["events"], summary: "Publish an event" },
112
+ body: t.Object({
113
+ message: t.String({ maxLength: 4_096 }),
114
+ topic: t.Optional(nameSchema),
115
+ audience: t.Optional(audienceSchema),
116
+ }),
117
+ detail: {
118
+ tags: ["events"],
119
+ summary: "Publish an event",
120
+ description: "Administrators only. Publishes `{ message }` to `topic` (default `message`).",
121
+ },
36
122
  });
37
- };
123
+ }
124
+
125
+ const eventsPlugin: AppPlugin = ({ services }) =>
126
+ eventsRoutes(
127
+ services.resolve(SSE_EVENTS),
128
+ services.resolve(REQUEST_IDENTITY),
129
+ services.tryResolve(EVENT_IDENTITY),
130
+ );
38
131
 
39
132
  export const eventsModule: PodokitModule = {
40
133
  name: "sse",