@podosoft/podokit 1.1.5 → 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 (168) 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 +17 -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/schema-validation.ts +13 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  109. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  113. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  114. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  115. package/dist/templates/modules/auth/module.manifest.json +2 -2
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  117. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  118. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  119. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  120. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  121. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  124. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  125. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  126. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  127. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  128. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  129. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  130. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  131. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  134. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  135. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  136. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  137. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  138. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  142. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  143. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  144. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  145. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  146. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  150. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  151. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  152. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  153. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  154. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  155. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  156. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  157. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  159. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  161. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  162. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  163. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  164. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  165. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  166. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  167. package/dist/templates/modules/sse/module.manifest.json +17 -4
  168. package/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { RedisClient } from "bun";
2
3
  import type {
3
4
  CacheSetOptions,
@@ -7,13 +8,19 @@ import type {
7
8
  } from "@podosoft/podokit-runtime";
8
9
  import type { RedisService } from "./redis.service";
9
10
 
10
- const FIXED_WINDOW_SCRIPT = `
11
+ export const FIXED_WINDOW_SCRIPT = `
11
12
  local count = redis.call('INCR', KEYS[1])
12
13
  if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
13
14
  local ttl = redis.call('PTTL', KEYS[1])
14
15
  return { count, ttl }
15
16
  `;
16
17
 
18
+ export const FIXED_WINDOW_SCRIPT_SHA = createHash("sha1").update(FIXED_WINDOW_SCRIPT).digest("hex");
19
+
20
+ function isMissingScript(error: unknown): boolean {
21
+ return error instanceof Error && error.message.startsWith("NOSCRIPT");
22
+ }
23
+
17
24
  function resultNumber(value: unknown): number | undefined {
18
25
  if (typeof value === "number") return value;
19
26
  if (typeof value === "string") {
@@ -35,13 +42,14 @@ export class RedisCacheStore implements CacheStore {
35
42
  }
36
43
 
37
44
  async set(key: string, value: string, options: CacheSetOptions = {}): Promise<void> {
38
- await this.client.set(key, value);
39
- if (options.ttlMs !== undefined) {
40
- if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1) {
41
- throw new Error("ttlMs must be a positive integer");
42
- }
43
- await this.client.send("PEXPIRE", [key, String(options.ttlMs)]);
45
+ if (options.ttlMs === undefined) {
46
+ await this.client.set(key, value);
47
+ return;
48
+ }
49
+ if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1) {
50
+ throw new Error("ttlMs must be a positive integer");
44
51
  }
52
+ await this.client.set(key, value, "PX", options.ttlMs);
45
53
  }
46
54
 
47
55
  async delete(key: string): Promise<boolean> {
@@ -58,12 +66,7 @@ export class RedisCacheStore implements CacheStore {
58
66
  if (!Number.isSafeInteger(options.limit) || options.limit < 1) {
59
67
  throw new Error("limit must be a positive integer");
60
68
  }
61
- const result = await this.client.send("EVAL", [
62
- FIXED_WINDOW_SCRIPT,
63
- "1",
64
- key,
65
- String(options.windowMs),
66
- ]);
69
+ const result = await this.runFixedWindow(key, options.windowMs);
67
70
  if (!Array.isArray(result)) throw new Error("Redis returned an invalid cache counter");
68
71
  const count = resultNumber(result[0]);
69
72
  const ttlMs = resultNumber(result[1]);
@@ -78,5 +81,16 @@ export class RedisCacheStore implements CacheStore {
78
81
  };
79
82
  }
80
83
 
84
+ private async runFixedWindow(key: string, windowMs: number): Promise<unknown> {
85
+ const args = ["1", key, String(windowMs)];
86
+ try {
87
+ return await this.client.send("EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, ...args]);
88
+ } catch (error) {
89
+ // Redis forgets scripts on restart, failover, or SCRIPT FLUSH; EVAL reloads it.
90
+ if (!isMissingScript(error)) throw error;
91
+ return this.client.send("EVAL", [FIXED_WINDOW_SCRIPT, ...args]);
92
+ }
93
+ }
94
+
81
95
  close(): void {}
82
96
  }
@@ -0,0 +1,123 @@
1
+ /** Every listed field must match. Within a list, any entry matches. */
2
+ export interface EventAudience {
3
+ userIds?: string[];
4
+ roles?: string[];
5
+ organizationId?: string;
6
+ }
7
+
8
+ export interface EventEnvelope<T = unknown> {
9
+ topic: string;
10
+ /** Omit to deliver to every stream subscribed to the topic. */
11
+ audience?: EventAudience;
12
+ data: T;
13
+ /** Sent as the SSE `id:`. Defaults to a per-connection sequence number. */
14
+ id?: string;
15
+ }
16
+
17
+ /** The identity a stream was opened with, resolved once at connect. */
18
+ export interface EventSubscriber {
19
+ userId: string;
20
+ roles: readonly string[];
21
+ organizationId?: string;
22
+ }
23
+
24
+ /** Topics and event ids share one charset so they can never break SSE framing. */
25
+ export const EVENT_NAME_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$";
26
+ const EVENT_NAME = new RegExp(EVENT_NAME_PATTERN);
27
+
28
+ export class InvalidEventError extends TypeError {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "InvalidEventError";
32
+ }
33
+ }
34
+
35
+ export function isEventName(value: unknown): value is string {
36
+ return typeof value === "string" && EVENT_NAME.test(value);
37
+ }
38
+
39
+ function record(value: unknown): Record<string, unknown> | undefined {
40
+ return value !== null && typeof value === "object" && !Array.isArray(value)
41
+ ? value as Record<string, unknown>
42
+ : undefined;
43
+ }
44
+
45
+ function stringList(value: unknown): string[] | undefined {
46
+ if (!Array.isArray(value)) return undefined;
47
+ const items: string[] = [];
48
+ for (const item of value) {
49
+ if (typeof item !== "string" || item.length === 0) return undefined;
50
+ items.push(item);
51
+ }
52
+ return items;
53
+ }
54
+
55
+ function readAudience(value: unknown): EventAudience | string {
56
+ const source = record(value);
57
+ if (!source) return "audience must be an object";
58
+ const audience: EventAudience = {};
59
+ if (source.userIds !== undefined) {
60
+ const userIds = stringList(source.userIds);
61
+ if (!userIds) return "audience.userIds must be a list of non-empty strings";
62
+ audience.userIds = userIds;
63
+ }
64
+ if (source.roles !== undefined) {
65
+ const roles = stringList(source.roles);
66
+ if (!roles) return "audience.roles must be a list of non-empty strings";
67
+ audience.roles = roles;
68
+ }
69
+ if (source.organizationId !== undefined) {
70
+ if (typeof source.organizationId !== "string" || source.organizationId.length === 0) {
71
+ return "audience.organizationId must be a non-empty string";
72
+ }
73
+ audience.organizationId = source.organizationId;
74
+ }
75
+ // An empty audience is almost always a recipient list that failed to resolve;
76
+ // treating it as a broadcast would leak the event.
77
+ if (
78
+ audience.userIds === undefined &&
79
+ audience.roles === undefined &&
80
+ audience.organizationId === undefined
81
+ ) {
82
+ return "audience must set userIds, roles, or organizationId";
83
+ }
84
+ return audience;
85
+ }
86
+
87
+ /** Normalizes an envelope, or returns why it is invalid. */
88
+ export function readEnvelope(value: unknown): EventEnvelope | string {
89
+ const source = record(value);
90
+ if (!source) return "event must be an object";
91
+ if (!isEventName(source.topic)) return `topic must match ${EVENT_NAME_PATTERN}`;
92
+ if (source.id !== undefined && !isEventName(source.id)) {
93
+ return `id must match ${EVENT_NAME_PATTERN}`;
94
+ }
95
+ const envelope: EventEnvelope = { topic: source.topic, data: source.data };
96
+ if (source.id !== undefined) envelope.id = source.id;
97
+ if (source.audience !== undefined) {
98
+ const audience = readAudience(source.audience);
99
+ if (typeof audience === "string") return audience;
100
+ envelope.audience = audience;
101
+ }
102
+ return envelope;
103
+ }
104
+
105
+ export function validEnvelope(value: EventEnvelope): EventEnvelope {
106
+ const envelope = readEnvelope(value);
107
+ if (typeof envelope === "string") throw new InvalidEventError(`Invalid event: ${envelope}`);
108
+ return envelope;
109
+ }
110
+
111
+ export type AudienceMatcher = (subscriber: EventSubscriber) => boolean;
112
+
113
+ export function audienceMatcher(audience: EventAudience | undefined): AudienceMatcher {
114
+ if (!audience) return () => true;
115
+ const userIds = audience.userIds ? new Set(audience.userIds) : undefined;
116
+ const roles = audience.roles ? new Set(audience.roles) : undefined;
117
+ const organizationId = audience.organizationId;
118
+ if (!userIds && !roles && organizationId === undefined) return () => false;
119
+ return (subscriber) =>
120
+ (!userIds || userIds.has(subscriber.userId)) &&
121
+ (!roles || subscriber.roles.some((role) => roles.has(role))) &&
122
+ (organizationId === undefined || subscriber.organizationId === organizationId);
123
+ }
@@ -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
+ });