@podosoft/podokit 1.0.1 → 1.1.2

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 (118) hide show
  1. package/README.md +19 -5
  2. package/dist/add.d.ts +14 -1
  3. package/dist/add.js +42 -5
  4. package/dist/create.d.ts +4 -0
  5. package/dist/create.js +9 -0
  6. package/dist/deploy-compose-profile.d.ts +3 -0
  7. package/dist/deploy-compose-profile.js +74 -8
  8. package/dist/deploy-compose-render.d.ts +2 -1
  9. package/dist/deploy-compose-render.js +50 -5
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy-render.js +5 -5
  13. package/dist/deploy.js +157 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +41 -0
  16. package/dist/lib.d.ts +2 -0
  17. package/dist/lib.js +8 -2
  18. package/dist/lockfile.d.ts +6 -1
  19. package/dist/lockfile.js +22 -4
  20. package/dist/provider.d.ts +24 -0
  21. package/dist/provider.js +142 -0
  22. package/dist/remove.js +6 -3
  23. package/dist/templates/fullstack/AGENTS.md +5 -4
  24. package/dist/templates/fullstack/README.md +10 -3
  25. package/dist/templates/fullstack/apps/api/package.json +3 -2
  26. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  27. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  28. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  29. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  30. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  31. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  32. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  35. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  36. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  37. package/dist/templates/fullstack/dot-env.example +2 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +1 -1
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +10 -2
  45. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  49. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  50. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  51. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  58. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  59. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  61. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  62. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  63. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  64. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  65. package/dist/templates/modules/auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  67. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  68. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  69. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  70. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  71. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  72. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  73. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  75. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  76. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  77. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  78. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  79. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  80. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  81. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  83. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  84. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  85. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  87. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  88. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  89. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  90. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  91. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  92. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  93. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  94. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  95. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  96. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  97. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  98. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  99. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  100. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  101. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  102. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  103. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  104. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  105. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  106. package/dist/templates/modules/redis/module.manifest.json +2 -0
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  108. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  109. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  110. package/dist/templates/modules/sse/module.manifest.json +4 -6
  111. package/dist/templates/todo/AGENTS.md +1 -1
  112. package/dist/templates/todo/README.md +8 -3
  113. package/dist/toolchain.d.ts +1 -0
  114. package/dist/toolchain.js +4 -0
  115. package/package.json +2 -1
  116. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  117. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  118. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
3
3
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
4
  const origin = { origin: base };
5
5
 
6
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
6
+ async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]): Promise<import("@playwright/test").APIRequestContext> {
7
7
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
8
  const email = `storage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
9
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } }).catch(() => undefined);
@@ -12,17 +12,25 @@ async function session(playwright: import("@playwright/test").PlaywrightWorkerAr
12
12
 
13
13
  test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
14
14
  const ctx = await session(playwright);
15
- const key = `obj-${Date.now()}`;
16
- const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
17
- test.skip(put.status() >= 500, "object storage (MinIO/S3) not reachable");
18
- expect(put.ok()).toBeTruthy();
19
- expect(await put.json()).toMatchObject({ key });
15
+ const key = `obj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
16
+ try {
17
+ const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
18
+ test.skip(!process.env.S3_ENDPOINT && put.status() >= 500, "optional S3-compatible service not configured");
19
+ expect(put.ok()).toBeTruthy();
20
+ expect(await put.json()).toMatchObject({ key });
20
21
 
21
- const got = await (await ctx.get(`/api/storage/${key}`)).json();
22
- expect(got).toMatchObject({ key, content: "hello world" });
22
+ const read = await ctx.get(`/api/storage/${key}`);
23
+ expect(read.ok()).toBeTruthy();
24
+ expect(await read.json()).toMatchObject({ key, content: "hello world" });
23
25
 
24
- const pre = await (await ctx.get(`/api/storage/${key}/presigned`)).json();
25
- expect(String(pre.url)).toContain(key);
26
-
27
- await ctx.dispose();
26
+ const presigned = await ctx.get(`/api/storage/${key}/presigned`);
27
+ expect(presigned.ok()).toBeTruthy();
28
+ const pre = await presigned.json() as { url: string };
29
+ expect(new URL(pre.url).pathname).toContain(key);
30
+ const download = await ctx.get(pre.url);
31
+ expect(download.ok()).toBeTruthy();
32
+ expect(await download.text()).toBe("hello world");
33
+ } finally {
34
+ await ctx.dispose();
35
+ }
28
36
  });
@@ -1,7 +1,9 @@
1
1
  {
2
+ "manifestVersion": 3,
2
3
  "name": "object-storage-s3",
3
- "description": "Bun-native S3-compatible object storage for AWS S3 or MinIO with a StorageService and presigned URLs.",
4
+ "description": "Bun-native S3-compatible object storage for AWS S3 or MinIO-compatible services with a StorageService and presigned URLs.",
4
5
  "requires": [],
6
+ "provides": { "object-storage": "s3" },
5
7
  "requiredProjectFiles": [
6
8
  "apps/api/src/health/readiness.service.ts"
7
9
  ],
@@ -21,7 +23,7 @@
21
23
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "storageModule," }
22
24
  ],
23
25
  "instructions": [
24
- "Start MinIO for local dev: docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
26
+ "Start Silo for local dev: docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
25
27
  "Store an object: curl -XPUT localhost:5002/storage/hello -H 'content-type: application/json' -d '{\"content\":\"hi\"}'",
26
28
  "Read it back: curl localhost:5002/storage/hello",
27
29
  "Presigned URL: curl localhost:5002/storage/hello/presigned",
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
2
  import type { RequestGuardContext } from "../core/services";
3
3
  import type { RateLimitIdentity } from "./rate-limit.identity";
4
- import { RateLimiter, type RateLimitStorage } from "./rate-limit.module";
4
+ import { CacheRateLimitStorage, RateLimiter, type RateLimitStorage } from "./rate-limit.module";
5
+ import { MemoryCacheStore } from "@podosoft/podokit-runtime";
5
6
 
6
7
  const config = {
7
8
  keyPrefix: "podokit:test:rate-limit",
@@ -32,6 +33,15 @@ function context(path: string, method = "GET"): {
32
33
  }
33
34
 
34
35
  describe("RateLimiter", () => {
36
+ test("uses the selected cache fixed-window primitive", async () => {
37
+ let now = 1_000;
38
+ const storage = new CacheRateLimitStorage(new MemoryCacheStore({ now: () => now }));
39
+ expect(await storage.increment("user", 60)).toMatchObject({ count: 1 });
40
+ expect(await storage.increment("user", 60)).toMatchObject({ count: 2 });
41
+ now += 60_000;
42
+ expect(await storage.increment("user", 60)).toMatchObject({ count: 1 });
43
+ });
44
+
35
45
  test("applies general, authentication, and runtime profiles", async () => {
36
46
  const increment = mock(async (_key: string, _ttlSeconds: number) => ({
37
47
  count: 1,
@@ -44,17 +54,20 @@ describe("RateLimiter", () => {
44
54
  );
45
55
  await limiter.enforce(context("/ordinary").value);
46
56
  await limiter.enforce(context("/api/auth/sign-in", "POST").value);
57
+ await limiter.enforce(context("/api/auth/get-session").value);
47
58
  await limiter.enforce(context("/site/settings").value);
48
59
 
49
60
  expect(increment.mock.calls.map((call) => call.slice(1))).toEqual([
50
61
  [60],
51
62
  [60],
52
63
  [60],
64
+ [60],
53
65
  ]);
54
66
  expect(increment.mock.calls.map((call) => call[0])).toEqual([
55
67
  "podokit:test:rate-limit:general:user:test",
56
68
  "podokit:test:rate-limit:auth:user:test",
57
69
  "podokit:test:rate-limit:runtime:user:test",
70
+ "podokit:test:rate-limit:runtime:user:test",
58
71
  ]);
59
72
  });
60
73
 
@@ -1,4 +1,4 @@
1
- import type { RedisClient } from "bun";
1
+ import { CACHE, type CacheStore } from "@podosoft/podokit-runtime";
2
2
  import { AppException } from "../common/app-exception";
3
3
  import { API_KEY_VERIFIER } from "../api-key/api-key.module";
4
4
  import {
@@ -7,7 +7,6 @@ import {
7
7
  type RequestGuardContext,
8
8
  type ServiceKey,
9
9
  } from "../core/services";
10
- import { REDIS } from "../redis/redis.module";
11
10
  import { rateLimitConfig, type RateLimitConfig } from "./rate-limit.config";
12
11
  import { RateLimitIdentity } from "./rate-limit.identity";
13
12
 
@@ -20,39 +19,18 @@ export interface RateLimitStorage {
20
19
  increment(key: string, ttlSeconds: number): Promise<RateLimitIncrement>;
21
20
  }
22
21
 
23
- const INCREMENT_SCRIPT = `
24
- local count = redis.call('INCR', KEYS[1])
25
- if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
26
- local ttl = redis.call('TTL', KEYS[1])
27
- return { count, ttl }
28
- `;
29
-
30
- function resultNumber(value: unknown): number | undefined {
31
- if (typeof value === "number") return value;
32
- if (typeof value === "string") {
33
- const parsed = Number(value);
34
- return Number.isFinite(parsed) ? parsed : undefined;
35
- }
36
- return undefined;
37
- }
38
-
39
- export class RedisRateLimitStorage implements RateLimitStorage {
40
- constructor(private readonly client: RedisClient) {}
22
+ export class CacheRateLimitStorage implements RateLimitStorage {
23
+ constructor(private readonly cache: CacheStore) {}
41
24
 
42
25
  async increment(key: string, ttlSeconds: number): Promise<RateLimitIncrement> {
43
- const result = await this.client.send("EVAL", [
44
- INCREMENT_SCRIPT,
45
- "1",
46
- key,
47
- String(ttlSeconds),
48
- ]);
49
- if (!Array.isArray(result)) throw new Error("Redis returned an invalid rate-limit result");
50
- const count = resultNumber(result[0]);
51
- const retryAfterSeconds = resultNumber(result[1]);
52
- if (count === undefined || retryAfterSeconds === undefined) {
53
- throw new Error("Redis returned an invalid rate-limit counter");
54
- }
55
- return { count, retryAfterSeconds: Math.max(1, retryAfterSeconds) };
26
+ const result = await this.cache.incrementFixedWindow(key, {
27
+ windowMs: ttlSeconds * 1_000,
28
+ limit: Number.MAX_SAFE_INTEGER,
29
+ });
30
+ return {
31
+ count: result.count,
32
+ retryAfterSeconds: Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1_000)),
33
+ };
56
34
  }
57
35
  }
58
36
 
@@ -117,12 +95,15 @@ export class RateLimiter {
117
95
  }
118
96
 
119
97
  private profile(method: string, path: string): LimitProfile {
98
+ if (
99
+ method === "GET" &&
100
+ (path === "/api/auth/get-session" || path === "/site/settings")
101
+ ) {
102
+ return { name: "runtime", ttlSeconds: this.config.ttlSeconds, limit: this.config.runtimeLimit };
103
+ }
120
104
  if (path === "/api/auth" || path.startsWith("/api/auth/")) {
121
105
  return { name: "auth", ttlSeconds: this.config.authTtlSeconds, limit: this.config.authLimit };
122
106
  }
123
- if (method === "GET" && path === "/site/settings") {
124
- return { name: "runtime", ttlSeconds: this.config.ttlSeconds, limit: this.config.runtimeLimit };
125
- }
126
107
  return { name: "general", ttlSeconds: this.config.ttlSeconds, limit: this.config.limit };
127
108
  }
128
109
 
@@ -152,7 +133,7 @@ export const rateLimitModule: PodokitModule = {
152
133
  const limiter = new RateLimiter(
153
134
  rateLimitConfig(),
154
135
  new RateLimitIdentity(services.resolve(API_KEY_VERIFIER), services),
155
- new RedisRateLimitStorage(services.resolve(REDIS).client),
136
+ new CacheRateLimitStorage(services.resolve(CACHE)),
156
137
  );
157
138
  services.register(RATE_LIMITER, limiter);
158
139
  services.resolve(REQUEST_GUARDS).register((context) => limiter.enforce(context));
@@ -12,6 +12,7 @@ const origin = { origin: base };
12
12
  // value so the window is crossed in a handful of requests); otherwise fall back
13
13
  // to the module default and allow a little headroom.
14
14
  const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
15
+ const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
15
16
 
16
17
  async function session(
17
18
  playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
@@ -63,3 +64,14 @@ test("rate limit: health probes stay available while ordinary routes return 429
63
64
  });
64
65
  await ctx.dispose();
65
66
  });
67
+
68
+ test("rate limit: session reads do not consume the strict authentication budget", async ({
69
+ playwright,
70
+ }) => {
71
+ const ctx = await session(playwright);
72
+ for (let i = 0; i < authLimit + 5; i++) {
73
+ const response = await ctx.get("/api/auth/get-session");
74
+ expect(response.status(), `session request ${i + 1}`).toBe(200);
75
+ }
76
+ await ctx.dispose();
77
+ });
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "rate-limit",
3
- "description": "Distributed Elysia request limiting by authenticated user, validated API key, or trusted-proxy client address. Uses Bun Redis across replicas and returns stable 429/503 errors.",
4
- "requires": ["redis", "api-key-auth"],
3
+ "manifestVersion": 3,
4
+ "description": "Provider-neutral Elysia request limiting by authenticated user, validated API key, or trusted-proxy client address.",
5
+ "requires": ["api-key-auth"],
6
+ "requiresCapabilities": ["cache"],
5
7
  "targetApp": "api",
6
8
  "managedGlobs": [
7
9
  "apps/api/src/rate-limit/**",
@@ -15,7 +17,7 @@
15
17
  "# stricter authentication endpoint ceiling",
16
18
  "RATE_LIMIT_AUTH_TTL=60",
17
19
  "RATE_LIMIT_AUTH_MAX=20",
18
- "# higher ceiling for SSR runtime settings reads",
20
+ "# higher ceiling for SSR runtime settings and session reads",
19
21
  "RATE_LIMIT_RUNTIME_MAX=1000",
20
22
  "# trust no proxy hop by default; set the exact production proxy depth",
21
23
  "RATE_LIMIT_TRUSTED_PROXY_HOPS=0",
@@ -36,14 +38,14 @@
36
38
  }
37
39
  ],
38
40
  "instructions": [
39
- "Ensure Redis is running: docker compose -f infra/docker/docker-compose.yml up -d",
41
+ "Configure the selected cache provider before enabling request limits.",
40
42
  "Tune the general, authentication, and runtime limits in .env.",
41
43
  "Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
42
44
  "Set RATE_LIMIT_TRUSTED_PROXY_HOPS to the exact number of trusted reverse proxies; keep 0 for direct API access.",
43
45
  "Authenticated users and validated X-API-Key clients receive independent opaque counters; other traffic uses the trusted client address.",
44
- "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Redis failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
46
+ "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
45
47
  "Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
46
48
  "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
47
- "The atomic counter is shared across API replicas in Redis."
49
+ "Redis counters are shared across replicas; memory counters require one API replica."
48
50
  ]
49
51
  }
@@ -0,0 +1,82 @@
1
+ import type { RedisClient } from "bun";
2
+ import type {
3
+ CacheSetOptions,
4
+ CacheStore,
5
+ FixedWindowOptions,
6
+ FixedWindowResult,
7
+ } from "@podosoft/podokit-runtime";
8
+ import type { RedisService } from "./redis.service";
9
+
10
+ const FIXED_WINDOW_SCRIPT = `
11
+ local count = redis.call('INCR', KEYS[1])
12
+ if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
13
+ local ttl = redis.call('PTTL', KEYS[1])
14
+ return { count, ttl }
15
+ `;
16
+
17
+ function resultNumber(value: unknown): number | undefined {
18
+ if (typeof value === "number") return value;
19
+ if (typeof value === "string") {
20
+ const parsed = Number(value);
21
+ return Number.isFinite(parsed) ? parsed : undefined;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export class RedisCacheStore implements CacheStore {
27
+ private readonly client: RedisClient;
28
+
29
+ constructor(private readonly redis: RedisService) {
30
+ this.client = redis.client;
31
+ }
32
+
33
+ get(key: string): Promise<string | null> {
34
+ return this.redis.get(key);
35
+ }
36
+
37
+ 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)]);
44
+ }
45
+ }
46
+
47
+ async delete(key: string): Promise<boolean> {
48
+ return (await this.redis.del(key)) > 0;
49
+ }
50
+
51
+ async incrementFixedWindow(
52
+ key: string,
53
+ options: FixedWindowOptions,
54
+ ): Promise<FixedWindowResult> {
55
+ if (!Number.isSafeInteger(options.windowMs) || options.windowMs < 1) {
56
+ throw new Error("windowMs must be a positive integer");
57
+ }
58
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1) {
59
+ throw new Error("limit must be a positive integer");
60
+ }
61
+ const result = await this.client.send("EVAL", [
62
+ FIXED_WINDOW_SCRIPT,
63
+ "1",
64
+ key,
65
+ String(options.windowMs),
66
+ ]);
67
+ if (!Array.isArray(result)) throw new Error("Redis returned an invalid cache counter");
68
+ const count = resultNumber(result[0]);
69
+ const ttlMs = resultNumber(result[1]);
70
+ if (count === undefined || ttlMs === undefined) {
71
+ throw new Error("Redis returned an invalid cache counter");
72
+ }
73
+ return {
74
+ allowed: count <= options.limit,
75
+ count,
76
+ remaining: Math.max(0, options.limit - count),
77
+ resetAt: Date.now() + Math.max(1, ttlMs),
78
+ };
79
+ }
80
+
81
+ close(): void {}
82
+ }
@@ -1,4 +1,6 @@
1
1
  import { Elysia, t } from "elysia";
2
+ import { CACHE } from "@podosoft/podokit-runtime";
3
+ import { PROVIDERS } from "../config/providers";
2
4
  import {
3
5
  type AppPlugin,
4
6
  type PodokitModule,
@@ -6,10 +8,12 @@ import {
6
8
  type ServiceKey,
7
9
  } from "../core/services";
8
10
  import { RedisService } from "./redis.service";
11
+ import { RedisCacheStore } from "./redis-cache.store";
9
12
 
10
13
  export const REDIS = Symbol("redis") as ServiceKey<RedisService>;
11
14
 
12
15
  const cachePlugin: AppPlugin = ({ services }) => {
16
+ if (PROVIDERS.cache !== "redis") return new Elysia({ name: "podokit.redis.inactive" });
13
17
  const redis = services.resolve(REDIS);
14
18
  return new Elysia({ name: "podokit.redis" })
15
19
  .put("/cache/:key", async ({ params, body }) => {
@@ -35,8 +39,10 @@ const cachePlugin: AppPlugin = ({ services }) => {
35
39
  export const redisModule: PodokitModule = {
36
40
  name: "redis",
37
41
  configure: (_env, services): void => {
42
+ if (PROVIDERS.cache !== "redis") return;
38
43
  const redis = new RedisService(services.resolve(READINESS));
39
44
  services.register(REDIS, redis, () => redis.close());
45
+ services.register(CACHE, new RedisCacheStore(redis));
40
46
  services.onStart(() => redis.connect());
41
47
  },
42
48
  plugin: cachePlugin,
@@ -1,7 +1,9 @@
1
1
  {
2
+ "manifestVersion": 3,
2
3
  "name": "redis",
3
4
  "description": "Bun native Redis client with get/set/del and publish/subscribe, plus demo cache endpoints.",
4
5
  "requires": [],
6
+ "provides": { "cache": "redis" },
5
7
  "requiredProjectFiles": [
6
8
  "apps/api/src/config/redis.connection.ts",
7
9
  "apps/api/src/health/readiness.service.ts"
@@ -1,18 +1,17 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
+ import { EVENTS } from "@podosoft/podokit-runtime";
2
3
  import { Elysia, sse, t } from "elysia";
3
4
  import {
4
5
  type AppPlugin,
5
6
  type PodokitModule,
6
7
  READINESS,
7
- type ServiceKey,
8
8
  } from "../core/services";
9
9
  import { EventsService } from "./events.service";
10
- import { createEventsTransport } from "./events.transport";
11
10
 
12
- export const EVENTS = Symbol("events") as ServiceKey<EventsService>;
11
+ export const SSE_EVENTS = Symbol("sse-events") as import("../core/services").ServiceKey<EventsService>;
13
12
 
14
13
  const eventsPlugin: AppPlugin = ({ services }) => {
15
- const events = services.resolve(EVENTS);
14
+ const events = services.resolve(SSE_EVENTS);
16
15
  return new Elysia({ name: "podokit.events" })
17
16
  .get("/events/stream", async function* ({ request }) {
18
17
  for await (const data of events.stream(request.signal)) yield sse({ data });
@@ -40,8 +39,8 @@ const eventsPlugin: AppPlugin = ({ services }) => {
40
39
  export const eventsModule: PodokitModule = {
41
40
  name: "sse",
42
41
  configure: (_env, services): void => {
43
- const events = new EventsService(createEventsTransport(), services.resolve(READINESS));
44
- services.register(EVENTS, events, () => events.close());
42
+ const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS));
43
+ services.register(SSE_EVENTS, events, () => events.close());
45
44
  services.onStart(() => events.connect());
46
45
  },
47
46
  plugin: eventsPlugin,
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { ReadinessService } from "../health/readiness.service";
3
+ import { MemoryEventBus } from "@podosoft/podokit-runtime";
3
4
  import { EventsService } from "./events.service";
4
- import { MemoryEventsTransport } from "./events.transport";
5
5
 
6
6
  describe("EventsService", () => {
7
7
  it("publishes through the transport and delivers locally", async () => {
8
8
  const readiness = new ReadinessService();
9
- const service = new EventsService(new MemoryEventsTransport(), readiness);
9
+ const service = new EventsService(new MemoryEventBus(), readiness);
10
10
  const received: unknown[] = [];
11
11
  service.subscribe((data) => received.push(data));
12
12
  await service.connect();
@@ -18,12 +18,12 @@ describe("EventsService", () => {
18
18
 
19
19
  it("rejects events larger than the configured limit", async () => {
20
20
  const service = new EventsService(
21
- new MemoryEventsTransport({ SSE_MAX_EVENT_BYTES: "8" }),
21
+ new MemoryEventBus({ maxEventBytes: 8 }),
22
22
  new ReadinessService(),
23
23
  );
24
24
  await service.connect();
25
25
  await expect(service.publishAsync({ message: "too large" })).rejects.toThrow(
26
- "Event data exceeds SSE_MAX_EVENT_BYTES",
26
+ "Event exceeds maxEventBytes",
27
27
  );
28
28
  await service.close();
29
29
  });
@@ -1,19 +1,20 @@
1
1
  import { ReadinessService } from "../health/readiness.service";
2
- import type { EventsTransport } from "./events.transport";
2
+ import type { EventBus, Unsubscribe } from "@podosoft/podokit-runtime";
3
3
 
4
4
  type LocalHandler = (data: unknown) => void;
5
5
 
6
6
  export class EventsService {
7
7
  private readonly handlers = new Set<LocalHandler>();
8
8
  private unregisterReadiness?: () => void;
9
+ private unsubscribe?: Unsubscribe;
9
10
 
10
11
  constructor(
11
- private readonly transport: EventsTransport,
12
+ private readonly transport: EventBus,
12
13
  private readonly readiness: ReadinessService,
13
14
  ) {}
14
15
 
15
16
  async connect(): Promise<void> {
16
- await this.transport.connect((data) => this.publishLocal(data));
17
+ this.unsubscribe = await this.transport.subscribe("sse", (data) => this.publishLocal(data));
17
18
  this.unregisterReadiness = this.readiness.register("events", () => this.transport.ready());
18
19
  }
19
20
 
@@ -25,7 +26,7 @@ export class EventsService {
25
26
  }
26
27
 
27
28
  publishAsync(data: unknown): Promise<void> {
28
- return this.transport.publish(data);
29
+ return this.transport.publish("sse", data);
29
30
  }
30
31
 
31
32
  publishLocal(data: unknown): void {
@@ -66,7 +67,7 @@ export class EventsService {
66
67
 
67
68
  async close(): Promise<void> {
68
69
  this.unregisterReadiness?.();
70
+ await this.unsubscribe?.();
69
71
  this.handlers.clear();
70
- await this.transport.close();
71
72
  }
72
73
  }
@@ -1,16 +1,14 @@
1
1
  {
2
+ "manifestVersion": 3,
2
3
  "name": "sse",
3
4
  "description": "Server-Sent Events with memory or Redis transport for multi-replica delivery.",
4
- "requires": [],
5
+ "requiresCapabilities": ["events"],
5
6
  "requiredProjectFiles": [
6
- "apps/api/src/config/redis.connection.ts",
7
7
  "apps/api/src/health/readiness.service.ts"
8
8
  ],
9
9
  "targetApp": "api",
10
10
  "env": [
11
- "# Server-Sent Events transport: memory for one API replica, redis for multiple replicas",
12
- "SSE_TRANSPORT=memory",
13
- "SSE_REDIS_CHANNEL=podokit:events",
11
+ "# Maximum encoded Server-Sent Event payload",
14
12
  "SSE_MAX_EVENT_BYTES=65536"
15
13
  ],
16
14
  "inject": [
@@ -20,7 +18,7 @@
20
18
  "instructions": [
21
19
  "Stream events: curl -N localhost:5002/events/stream",
22
20
  "Publish a message (in another terminal): curl -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
23
- "Set SSE_TRANSPORT=redis for delivery across API replicas; Redis uses the shared REDIS_URL or individual Redis settings.",
21
+ "Select events=redis for multiple replicas or events=memory for a single process.",
24
22
  "Inject EventsService and call publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica."
25
23
  ]
26
24
  }
@@ -73,7 +73,7 @@ Useful commands:
73
73
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
74
74
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
75
75
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
76
- - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
76
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
77
77
  - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
78
78
 
79
79
  ## Commit conventions
@@ -40,7 +40,7 @@ In a second terminal, apply the included Todo migration and use the lifecycle he
40
40
  ```
41
41
 
42
42
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
43
- `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
43
+ `storage`, and `queue` when installed modules require Redis, Silo, or a worker.
44
44
  `dev up` uses the same shared gateway and module profiles without keeping Compose
45
45
  Watch attached.
46
46
  You can still activate an additional Compose profile explicitly:
@@ -90,6 +90,11 @@ a sample Todo repository and initial migration are included.
90
90
 
91
91
  Docker Compose lives in `infra/docker`; the basic k3s manifests in `infra/k3s`
92
92
  route every public path through the web proxy. Use `podo deploy init`, `doctor`,
93
- and `plan` for an exact-image Helm release on an existing cluster. Applying or
94
- rolling back requires the exact confirmation hash from a fresh plan. See the
93
+ and `plan` for an exact-image release on Kubernetes or one Docker host. Applying
94
+ or rolling back requires the exact confirmation hash from a fresh plan. See the
95
95
  PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/docs/deployment.md).
96
+
97
+ For a Docker Compose deployment with API WebSockets, list their exact paths under
98
+ `exposure.webSocketPaths`. The driver keeps one published port, routes those paths
99
+ to API, and sends every other request to web. If an external reverse proxy supplies
100
+ forwarding headers, list only its direct CIDR blocks in `exposure.trustedProxyCidrs`.
@@ -11,3 +11,4 @@ export declare function resolveToolchain(runtime?: string, packageManager?: stri
11
11
  export declare function toolchainTemplateVars(toolchain: Toolchain): TemplateVars;
12
12
  export declare function isRuntime(value: string): value is Runtime;
13
13
  export declare function toolchainMigrationCommand(_toolchain: Toolchain): string[];
14
+ export declare function toolchainWorkerCommand(_toolchain: Toolchain): string[];
package/dist/toolchain.js CHANGED
@@ -5,6 +5,7 @@ exports.resolveToolchain = resolveToolchain;
5
5
  exports.toolchainTemplateVars = toolchainTemplateVars;
6
6
  exports.isRuntime = isRuntime;
7
7
  exports.toolchainMigrationCommand = toolchainMigrationCommand;
8
+ exports.toolchainWorkerCommand = toolchainWorkerCommand;
8
9
  exports.BUN_VERSION = "1.4.0";
9
10
  function resolveToolchain(runtime = "bun", packageManager) {
10
11
  if (runtime !== "bun") {
@@ -42,3 +43,6 @@ function isRuntime(value) {
42
43
  function toolchainMigrationCommand(_toolchain) {
43
44
  return ["bun", "run", "migrate:all"];
44
45
  }
46
+ function toolchainWorkerCommand(_toolchain) {
47
+ return ["bun", "dist/main-worker.js"];
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.0.1",
3
+ "version": "1.1.2",
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,6 +45,7 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
+ "@podosoft/podokit-runtime": "^0.2.0",
48
49
  "@podosoft/podokit-template-engine": "^0.4.2"
49
50
  }
50
51
  }
@@ -1,32 +0,0 @@
1
- import { RedisService } from "../redis/redis.service";
2
- import { EventsService } from "../events/events.service";
3
- import { PROGRESS_CHANNEL } from "./progress.processor";
4
-
5
- // Runs in the API process: relays worker progress (Redis pub/sub) to SSE.
6
- export class ProgressBridge {
7
- private unsubscribe?: () => Promise<void>;
8
-
9
- constructor(
10
- private readonly redis: RedisService,
11
- private readonly events: EventsService,
12
- ) {}
13
-
14
- async connect(): Promise<void> {
15
- this.unsubscribe = await this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
16
- try {
17
- const parsed: unknown = JSON.parse(message);
18
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
19
- process.stderr.write("Discard invalid job progress event\n");
20
- return;
21
- }
22
- this.events.publishLocal({ type: "job-progress", ...parsed });
23
- } catch {
24
- process.stderr.write("Discard malformed job progress event\n");
25
- }
26
- });
27
- }
28
-
29
- async close(): Promise<void> {
30
- await this.unsubscribe?.();
31
- }
32
- }
@@ -1,13 +0,0 @@
1
- import { Pool } from "pg";
2
-
3
- // The mailer's own pg pool, in its own module so the mailer stays independent of
4
- // any other module. Used only to read the optional admin-configured SMTP settings
5
- // from `auth_config` when the auth module is present; with no such table the read
6
- // fails softly and the mailer falls back to SMTP_* env (see mailer.ts).
7
- export const pool = new Pool({
8
- host: process.env.POSTGRES_HOST ?? "localhost",
9
- port: Number(process.env.POSTGRES_PORT ?? 5432),
10
- user: process.env.POSTGRES_USER ?? "podokit",
11
- password: process.env.POSTGRES_PASSWORD ?? "podokit",
12
- database: process.env.POSTGRES_DB ?? "podokit",
13
- });