@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,51 @@
1
+ export const UPLOAD_KEY_PREFIX = "uploads/";
2
+
3
+ const OWNER_SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
4
+ const MAX_NAME_LENGTH = 128;
5
+
6
+ export type UploadAccess = "allowed" | "forbidden" | "not-found";
7
+
8
+ export interface UploadRequester {
9
+ userId: string | undefined;
10
+ admin: boolean;
11
+ }
12
+
13
+ /**
14
+ * Keys are `uploads/<userId>/<uuid>-<name>`. Without a signed-in user (an app
15
+ * without the auth module) the key has no owner segment, which is also the
16
+ * format every upload had before ownership existed.
17
+ */
18
+ export function uploadKey(
19
+ userId: string | undefined,
20
+ fileName: string,
21
+ id: string = crypto.randomUUID(),
22
+ ): string {
23
+ const safeName = fileName.replace(/[^\w.-]+/g, "_").slice(-MAX_NAME_LENGTH);
24
+ const name = `${id}-${safeName}`;
25
+ if (userId === undefined) return `${UPLOAD_KEY_PREFIX}${name}`;
26
+ if (!OWNER_SEGMENT.test(userId)) {
27
+ throw new Error("The user id cannot be used as an upload key segment");
28
+ }
29
+ return `${UPLOAD_KEY_PREFIX}${userId}/${name}`;
30
+ }
31
+
32
+ /**
33
+ * Owners and administrators may read owned uploads. Unowned uploads are
34
+ * readable by administrators, and by requests without a user only when no
35
+ * identity provider is installed; with auth, such requests never reach the
36
+ * route because the session guard rejects them first.
37
+ */
38
+ export function uploadAccess(key: string, requester: UploadRequester): UploadAccess {
39
+ if (!key.startsWith(UPLOAD_KEY_PREFIX)) return "not-found";
40
+ const segments = key.slice(UPLOAD_KEY_PREFIX.length).split("/");
41
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
42
+ return "not-found";
43
+ }
44
+ if (segments.length === 1) {
45
+ return requester.admin || requester.userId === undefined ? "allowed" : "forbidden";
46
+ }
47
+ if (segments.length === 2) {
48
+ return requester.admin || segments[0] === requester.userId ? "allowed" : "forbidden";
49
+ }
50
+ return "not-found";
51
+ }
@@ -1,20 +1,46 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type APIRequestContext, type APIResponse, type PlaywrightWorkerArgs } from "@playwright/test";
2
2
 
3
3
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
4
  const origin = { origin: base };
5
5
 
6
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
6
+ type Playwright = PlaywrightWorkerArgs["playwright"];
7
+
8
+ interface Session {
9
+ ctx: APIRequestContext;
10
+ /** Undefined when the app has no auth module. */
11
+ userId: string | undefined;
12
+ }
13
+
14
+ async function session(playwright: Playwright): Promise<Session> {
7
15
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
16
  const email = `files-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
17
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "F" } }).catch(() => undefined);
10
- return ctx;
18
+ const current = await ctx.get("/api/auth/get-session");
19
+ const body = current.ok() ? await current.json() as { user?: { id?: string } } | null : null;
20
+ return { ctx, userId: body?.user?.id };
11
21
  }
12
22
 
13
- test("file upload: multipart upload returns a key and url @smoke", async ({ playwright }) => {
14
- const ctx = await session(playwright);
15
- const res = await ctx.post("/api/files", {
16
- multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from("hi there") } },
23
+ function upload(ctx: APIRequestContext, text = "hi there"): Promise<APIResponse> {
24
+ return ctx.post("/api/files", {
25
+ multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from(text) } },
17
26
  });
27
+ }
28
+
29
+ async function uploadedKey(ctx: APIRequestContext): Promise<string> {
30
+ const res = await upload(ctx);
31
+ test.skip(res.status() >= 500, "selected object storage is not reachable");
32
+ expect(res.status(), await res.text()).toBe(201);
33
+ return (await res.json() as { key: string }).key;
34
+ }
35
+
36
+ // Keys contain slashes, so they travel URL-encoded as one path segment.
37
+ function presign(ctx: APIRequestContext, key: string): Promise<APIResponse> {
38
+ return ctx.get(`/api/files/${encodeURIComponent(key)}/url`);
39
+ }
40
+
41
+ test("file upload: multipart upload returns a key and url @smoke", async ({ playwright }) => {
42
+ const { ctx, userId } = await session(playwright);
43
+ const res = await upload(ctx);
18
44
  const responseText = await res.text();
19
45
  test.skip(res.status() >= 500, "selected object storage is not reachable");
20
46
  expect(res.ok(), `upload returned ${res.status()}: ${responseText}`).toBeTruthy();
@@ -24,13 +50,48 @@ test("file upload: multipart upload returns a key and url @smoke", async ({ play
24
50
  if (typeof body !== "object" || body === null) throw new Error("upload response must be an object");
25
51
  const response = body as Record<string, unknown>;
26
52
  expect(typeof response.key).toBe("string");
27
- expect(String(response.url)).toContain("http");
53
+ if (userId) expect(String(response.key)).toMatch(new RegExp(`^uploads/${userId}/[0-9a-f-]{36}-hello\\.txt$`));
54
+ // S3 returns an absolute presigned URL; local storage returns a signed API path.
55
+ expect(String(response.url)).toMatch(/^(https?:\/\/|\/files\/content\/)/);
28
56
  await ctx.dispose();
29
57
  });
30
58
 
31
59
  test("file upload: a request with no file is a 400", async ({ playwright }) => {
32
- const ctx = await session(playwright);
60
+ const { ctx } = await session(playwright);
33
61
  const res = await ctx.post("/api/files", { multipart: { notfile: "x" } });
34
62
  expect(res.status()).toBe(400);
35
63
  await ctx.dispose();
36
64
  });
65
+
66
+ test("file upload: the uploader can presign their own file", async ({ playwright }) => {
67
+ const { ctx } = await session(playwright);
68
+ try {
69
+ const key = await uploadedKey(ctx);
70
+ const res = await presign(ctx, key);
71
+ expect(res.status(), await res.text()).toBe(200);
72
+ const { url } = await res.json() as { url: string };
73
+ expect(url).toContain(key.split("/").pop() ?? key);
74
+ } finally {
75
+ await ctx.dispose();
76
+ }
77
+ });
78
+
79
+ test("file upload: users cannot presign another user's file", async ({ playwright }) => {
80
+ const owner = await session(playwright);
81
+ const other = await session(playwright);
82
+ try {
83
+ test.skip(!owner.userId || !other.userId, "requires the auth module");
84
+ const key = await uploadedKey(owner.ctx);
85
+
86
+ const forbidden = await presign(other.ctx, key);
87
+ expect(forbidden.status()).toBe(403);
88
+ expect(await forbidden.json()).toMatchObject({ error: { code: "FILE_FORBIDDEN" } });
89
+
90
+ const foreign = await presign(other.ctx, "site/favicon");
91
+ expect(foreign.status()).toBe(404);
92
+ expect(await foreign.json()).toMatchObject({ error: { code: "FILE_NOT_FOUND" } });
93
+ } finally {
94
+ await owner.ctx.dispose();
95
+ await other.ctx.dispose();
96
+ }
97
+ });
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "manifestVersion": 3,
3
3
  "name": "file-upload",
4
- "description": "Multipart file upload endpoint that stores files via object storage and returns a presigned URL.",
4
+ "description": "Multipart file upload endpoint that stores each user's files via object storage and presigns them only for the uploader or an administrator.",
5
5
  "requiresCapabilities": ["object-storage"],
6
6
  "targetApp": "api",
7
+ "requiredProjectContents": {
8
+ "apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
9
+ },
7
10
  "env": [],
8
11
  "inject": [
9
12
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { filesModule } from \"./files/files.module\";" },
@@ -11,7 +14,10 @@
11
14
  ],
12
15
  "instructions": [
13
16
  "Configure the selected object-storage provider before uploading files.",
14
- "Upload a file: curl -F 'file=@./some.png' localhost:5002/files",
15
- "The response includes { key, url } — an S3 presigned URL or a local download URL."
17
+ "Uploads are stored as uploads/<userId>/<uuid>-<name>. Add the auth module so every upload has an owner.",
18
+ "Upload a file: curl -b cookies.txt -F 'file=@./some.png' localhost:5002/files",
19
+ "The response includes { key, url } — an S3 presigned URL or a signed local download URL.",
20
+ "Presign it again (owner or administrator, key URL-encoded): curl -b cookies.txt localhost:5002/files/uploads%2F<userId>%2F<file>/url",
21
+ "Uploads stored before per-user keys (uploads/<file>) can only be presigned by administrators."
16
22
  ]
17
23
  }
@@ -1,46 +1,40 @@
1
- import { JOBS, type JobQueue, type Unsubscribe } from "@podosoft/podokit-runtime";
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { JOBS, type JobQueue } from "@podosoft/podokit-runtime";
2
3
  import { Elysia, t } from "elysia";
3
- import type { AppPlugin, PodokitModule, ServiceKey } from "../core/services";
4
+ import {
5
+ REQUEST_IDENTITY,
6
+ type AppPlugin,
7
+ type ElysiaPlugin,
8
+ type PodokitModule,
9
+ type RequestIdentity,
10
+ type ServiceKey,
11
+ } from "../core/services";
4
12
  import { SSE_EVENTS } from "../events/events.module";
5
- import { processProgress } from "./progress.processor";
6
-
7
- class ProgressWorker {
8
- private unsubscribe?: Unsubscribe;
9
-
10
- constructor(
11
- private readonly queue: JobQueue,
12
- private readonly publish: (event: unknown) => Promise<void>,
13
- ) {}
14
-
15
- async connect(): Promise<void> {
16
- this.unsubscribe = await this.queue.process("progress", async (payload, job) => {
17
- await processProgress(payload, (progress) => this.publish({
18
- type: "job-progress",
19
- jobId: job.id,
20
- progress,
21
- }));
22
- });
23
- }
24
-
25
- async close(): Promise<void> {
26
- await this.unsubscribe?.();
27
- }
28
- }
13
+ import { PROGRESS_JOB } from "./progress.processor";
14
+ import { ProgressWorker } from "./progress.worker";
29
15
 
30
16
  const PROGRESS_WORKER = Symbol("progress-worker") as ServiceKey<ProgressWorker>;
31
17
 
32
- const progressPlugin: AppPlugin = ({ services }) => {
33
- const queue = services.resolve(JOBS);
18
+ export function progressRoutes(queue: JobQueue, identity: RequestIdentity): ElysiaPlugin {
34
19
  return new Elysia({ name: "podokit.job-progress" })
35
- .post("/progress", async ({ body, set }) => {
36
- const job = await queue.enqueue("progress", { steps: body.steps ?? 5 });
20
+ .post("/progress", async ({ request, body, set }) => {
21
+ const ownerId = await identity.userId(request);
22
+ if (!ownerId) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
23
+ const job = await queue.enqueue(PROGRESS_JOB, { steps: body.steps ?? 5, ownerId });
37
24
  set.status = 201;
38
25
  return { jobId: job.id };
39
26
  }, {
40
27
  body: t.Object({ steps: t.Optional(t.Integer({ minimum: 1, maximum: 100 })) }),
41
- detail: { tags: ["progress"], summary: "Start a progress job" },
28
+ detail: {
29
+ tags: ["progress"],
30
+ summary: "Start a progress job",
31
+ description: "Progress is published on the `job-progress` topic to the caller only.",
32
+ },
42
33
  });
43
- };
34
+ }
35
+
36
+ const progressPlugin: AppPlugin = ({ services }) =>
37
+ progressRoutes(services.resolve(JOBS), services.resolve(REQUEST_IDENTITY));
44
38
 
45
39
  export const jobProgressModule: PodokitModule = {
46
40
  name: "job-progress",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
- import { processProgress } from "./progress.processor";
2
+ import { processProgress, progressEvent, progressPayload } from "./progress.processor";
3
3
 
4
4
  describe("progress processor", () => {
5
5
  it("publishes provider-neutral progress updates", async () => {
@@ -10,4 +10,19 @@ describe("progress processor", () => {
10
10
  expect(values).toEqual([25, 50, 75, 100]);
11
11
  expect(delay).toHaveBeenCalledTimes(4);
12
12
  });
13
+
14
+ it("reads the owner from the job payload", () => {
15
+ expect(progressPayload({ steps: 2, ownerId: "alice" })).toEqual({ steps: 2, ownerId: "alice" });
16
+ expect(progressPayload({ ownerId: "" })).toEqual({});
17
+ expect(progressPayload({ ownerId: 7 })).toEqual({});
18
+ expect(progressPayload(null)).toEqual({});
19
+ });
20
+
21
+ it("addresses progress events to the owner", () => {
22
+ expect(progressEvent("job-1", "alice", 40)).toEqual({
23
+ topic: "job-progress",
24
+ audience: { userIds: ["alice"] },
25
+ data: { jobId: "job-1", progress: 40 },
26
+ });
27
+ });
13
28
  });
@@ -1,11 +1,38 @@
1
+ import type { EventEnvelope } from "../events/event-envelope";
2
+
3
+ export const PROGRESS_JOB = "progress";
4
+ export const JOB_PROGRESS_TOPIC = "job-progress";
5
+
1
6
  export interface ProgressPayload {
2
7
  steps?: number;
8
+ /** The user who started the job; only that user receives its progress. */
9
+ ownerId?: string;
10
+ }
11
+
12
+ export interface JobProgress {
13
+ jobId: string;
14
+ progress: number;
3
15
  }
4
16
 
5
- function payload(value: unknown): ProgressPayload {
17
+ export function progressPayload(value: unknown): ProgressPayload {
6
18
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
7
- const steps = (value as Record<string, unknown>).steps;
8
- return typeof steps === "number" ? { steps } : {};
19
+ const { steps, ownerId } = value as Record<string, unknown>;
20
+ return {
21
+ ...(typeof steps === "number" ? { steps } : {}),
22
+ ...(typeof ownerId === "string" && ownerId.length > 0 ? { ownerId } : {}),
23
+ };
24
+ }
25
+
26
+ export function progressEvent(
27
+ jobId: string,
28
+ ownerId: string,
29
+ progress: number,
30
+ ): EventEnvelope<JobProgress> {
31
+ return {
32
+ topic: JOB_PROGRESS_TOPIC,
33
+ audience: { userIds: [ownerId] },
34
+ data: { jobId, progress },
35
+ };
9
36
  }
10
37
 
11
38
  export async function processProgress(
@@ -13,7 +40,7 @@ export async function processProgress(
13
40
  publish: (progress: number) => Promise<void>,
14
41
  delay: (milliseconds: number) => Promise<unknown> = Bun.sleep,
15
42
  ): Promise<{ done: true }> {
16
- const steps = payload(value).steps ?? 5;
43
+ const steps = progressPayload(value).steps ?? 5;
17
44
  for (let index = 1; index <= steps; index += 1) {
18
45
  await delay(400);
19
46
  const progress = Math.round((index / steps) * 100);
@@ -0,0 +1,145 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import {
4
+ MemoryEventBus,
5
+ type JobHandler,
6
+ type JobQueue,
7
+ type JobReference,
8
+ } from "@podosoft/podokit-runtime";
9
+ import { Elysia } from "elysia";
10
+ import type { RequestIdentity } from "../core/services";
11
+ import type { EventStream, StreamFrame } from "../events/event-stream";
12
+ import { EventsService } from "../events/events.service";
13
+ import { ReadinessService } from "../health/readiness.service";
14
+ import { progressRoutes } from "./job-progress.module";
15
+ import { JOB_PROGRESS_TOPIC } from "./progress.processor";
16
+ import { ProgressWorker } from "./progress.worker";
17
+
18
+ interface FakeQueue extends JobQueue {
19
+ enqueued: Array<{ name: string; payload: unknown }>;
20
+ run(payload: unknown, job: JobReference): Promise<void>;
21
+ }
22
+
23
+ function fakeQueue(): FakeQueue {
24
+ const handlers = new Map<string, JobHandler>();
25
+ const enqueued: Array<{ name: string; payload: unknown }> = [];
26
+ return {
27
+ enqueued,
28
+ enqueue: async (name, payload) => {
29
+ enqueued.push({ name, payload });
30
+ return { id: `job-${enqueued.length}`, name };
31
+ },
32
+ get: async () => null,
33
+ process: async (name, handler) => {
34
+ handlers.set(name, handler);
35
+ return () => {
36
+ handlers.delete(name);
37
+ };
38
+ },
39
+ run: async (payload, job) => {
40
+ const handler = handlers.get(job.name);
41
+ if (!handler) throw new Error(`no handler for ${job.name}`);
42
+ await handler(payload, job);
43
+ },
44
+ close: () => undefined,
45
+ };
46
+ }
47
+
48
+ const cleanups: Array<() => Promise<void>> = [];
49
+
50
+ afterEach(async () => {
51
+ for (const cleanup of cleanups.splice(0)) await cleanup();
52
+ });
53
+
54
+ async function harness(): Promise<{ events: EventsService; queue: FakeQueue }> {
55
+ const events = new EventsService(new MemoryEventBus(), new ReadinessService());
56
+ await events.connect();
57
+ const queue = fakeQueue();
58
+ const worker = new ProgressWorker(queue, (event) => events.publishAsync(event), async () => undefined);
59
+ await worker.connect();
60
+ cleanups.push(async () => {
61
+ await worker.close();
62
+ await events.close();
63
+ });
64
+ return { events, queue };
65
+ }
66
+
67
+ function stream(events: EventsService, userId: string): Promise<EventStream> {
68
+ return events.open({ userId, roles: [] }, [JOB_PROGRESS_TOPIC, "marker"], new AbortController().signal);
69
+ }
70
+
71
+ function decoded(frame: StreamFrame | undefined): unknown {
72
+ return frame?.kind === "event" ? JSON.parse(frame.data) : frame;
73
+ }
74
+
75
+ async function progressBeforeMarker(events: EventsService, target: EventStream): Promise<unknown[]> {
76
+ await events.publishAsync({ topic: "marker", data: "end" });
77
+ const frames: unknown[] = [];
78
+ for (;;) {
79
+ const frame = decoded(await target.next());
80
+ if (JSON.stringify(frame) === JSON.stringify({ topic: "marker", data: "end" })) return frames;
81
+ frames.push(frame);
82
+ }
83
+ }
84
+
85
+ describe("progress worker", () => {
86
+ it("sends a job's progress only to the user who started it", async () => {
87
+ const { events, queue } = await harness();
88
+ const owner = await stream(events, "alice");
89
+ const other = await stream(events, "bob");
90
+
91
+ await queue.run({ steps: 2, ownerId: "alice" }, { id: "job-1", name: "progress" });
92
+
93
+ expect(await progressBeforeMarker(events, owner)).toEqual([
94
+ { topic: JOB_PROGRESS_TOPIC, data: { jobId: "job-1", progress: 50 } },
95
+ { topic: JOB_PROGRESS_TOPIC, data: { jobId: "job-1", progress: 100 } },
96
+ ]);
97
+ expect(await progressBeforeMarker(events, other)).toEqual([]);
98
+ });
99
+
100
+ it("publishes nothing for a job without an owner", async () => {
101
+ const { events, queue } = await harness();
102
+ const listener = await stream(events, "alice");
103
+ await queue.run({ steps: 2 }, { id: "legacy", name: "progress" });
104
+ expect(await progressBeforeMarker(events, listener)).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("progress routes", () => {
109
+ const identity = (userId: string | undefined): RequestIdentity => ({
110
+ userId: async () => userId,
111
+ isAdmin: async () => false,
112
+ roles: async () => [],
113
+ activeOrganizationId: async () => undefined,
114
+ });
115
+
116
+ async function start(queue: JobQueue, userId: string | undefined): Promise<Response> {
117
+ const app = new Elysia()
118
+ .onError(({ error, set }) => {
119
+ if (!(error instanceof AppException)) return undefined;
120
+ set.status = error.statusCode;
121
+ return { code: error.code };
122
+ })
123
+ .use(progressRoutes(queue, identity(userId)));
124
+ return app.handle(new Request("http://localhost/progress", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify({ steps: 3 }),
128
+ }));
129
+ }
130
+
131
+ it("records the caller as the job owner", async () => {
132
+ const queue = fakeQueue();
133
+ const response = await start(queue, "alice");
134
+ expect(response.status).toBe(201);
135
+ expect(await response.json()).toEqual({ jobId: "job-1" });
136
+ expect(queue.enqueued).toEqual([{ name: "progress", payload: { steps: 3, ownerId: "alice" } }]);
137
+ });
138
+
139
+ it("requires a signed-in user", async () => {
140
+ const queue = fakeQueue();
141
+ const response = await start(queue, undefined);
142
+ expect(response.status).toBe(401);
143
+ expect(queue.enqueued).toEqual([]);
144
+ });
145
+ });
@@ -0,0 +1,37 @@
1
+ import type { JobQueue, Unsubscribe } from "@podosoft/podokit-runtime";
2
+ import type { EventEnvelope } from "../events/event-envelope";
3
+ import {
4
+ PROGRESS_JOB,
5
+ processProgress,
6
+ progressEvent,
7
+ progressPayload,
8
+ } from "./progress.processor";
9
+
10
+ export class ProgressWorker {
11
+ private unsubscribe?: Unsubscribe;
12
+
13
+ constructor(
14
+ private readonly queue: JobQueue,
15
+ private readonly publish: (event: EventEnvelope) => Promise<void>,
16
+ private readonly delay?: (milliseconds: number) => Promise<unknown>,
17
+ ) {}
18
+
19
+ async connect(): Promise<void> {
20
+ this.unsubscribe = await this.queue.process(PROGRESS_JOB, async (payload, job) => {
21
+ // Jobs enqueued before progress had owners run without publishing,
22
+ // because there is nobody they may be shown to.
23
+ const ownerId = progressPayload(payload).ownerId;
24
+ await processProgress(
25
+ payload,
26
+ async (progress) => {
27
+ if (ownerId) await this.publish(progressEvent(job.id, ownerId, progress));
28
+ },
29
+ this.delay,
30
+ );
31
+ });
32
+ }
33
+
34
+ async close(): Promise<void> {
35
+ await this.unsubscribe?.();
36
+ }
37
+ }
@@ -1,17 +1,78 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
2
2
 
3
3
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
4
  const origin = { origin: base };
5
5
 
6
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
6
+ type Playwright = PlaywrightWorkerArgs["playwright"];
7
+
8
+ async function session(playwright: Playwright): Promise<APIRequestContext> {
7
9
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
10
  const email = `progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
11
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "P" } }).catch(() => undefined);
10
12
  return ctx;
11
13
  }
12
14
 
13
- // The end-to-end progress stream (worker → Redis pub/sub → SSE) needs the worker
14
- // process running; this asserts the API contract (a job is enqueued).
15
+ async function signedIn(ctx: APIRequestContext): Promise<boolean> {
16
+ const response = await ctx.get("/api/auth/get-session");
17
+ if (!response.ok()) return false;
18
+ const body = await response.json() as { user?: { id?: string } } | null;
19
+ return Boolean(body?.user?.id);
20
+ }
21
+
22
+ class ProgressStream {
23
+ private readonly decoder = new TextDecoder();
24
+ private buffered = "";
25
+
26
+ private constructor(
27
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>,
28
+ private readonly controller: AbortController,
29
+ ) {}
30
+
31
+ static async open(ctx: APIRequestContext): Promise<ProgressStream> {
32
+ const controller = new AbortController();
33
+ const cookie = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
34
+ const response = await fetch(`${base}/api/events/stream?topics=job-progress`, {
35
+ headers: { ...origin, cookie },
36
+ signal: controller.signal,
37
+ });
38
+ expect(response.status).toBe(200);
39
+ const reader = response.body?.getReader();
40
+ if (!reader) throw new Error("event stream has no body");
41
+ const stream = new ProgressStream(reader, controller);
42
+ await stream.until("retry:");
43
+ return stream;
44
+ }
45
+
46
+ async until(text: string, timeoutMs = 15_000): Promise<string> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ while (!this.buffered.includes(text)) {
49
+ const remaining = deadline - Date.now();
50
+ if (remaining <= 0) throw new Error(`timed out waiting for ${text}`);
51
+ let timer: ReturnType<typeof setTimeout> | undefined;
52
+ const timeout = new Promise<"timeout">((resolve) => {
53
+ timer = setTimeout(() => resolve("timeout"), remaining);
54
+ });
55
+ const result = await Promise.race([this.reader.read(), timeout]);
56
+ clearTimeout(timer);
57
+ if (result === "timeout") continue;
58
+ if (result.done) throw new Error(`stream ended before ${text}`);
59
+ this.buffered += this.decoder.decode(result.value, { stream: true });
60
+ }
61
+ const end = this.buffered.indexOf(text) + text.length;
62
+ const seen = this.buffered.slice(0, end);
63
+ this.buffered = this.buffered.slice(end);
64
+ return seen;
65
+ }
66
+
67
+ close(): void {
68
+ this.controller.abort();
69
+ }
70
+ }
71
+
72
+ function completed(jobId: string): string {
73
+ return JSON.stringify({ topic: "job-progress", data: { jobId, progress: 100 } });
74
+ }
75
+
15
76
  test("job-progress: starting a job returns a job id @smoke", async ({ playwright }) => {
16
77
  const ctx = await session(playwright);
17
78
  const res = await ctx.post("/api/progress", { data: { steps: 3 } });
@@ -20,3 +81,32 @@ test("job-progress: starting a job returns a job id @smoke", async ({ playwright
20
81
  expect(typeof (await res.json()).jobId).toBe("string");
21
82
  await ctx.dispose();
22
83
  });
84
+
85
+ test("job-progress: progress reaches only the user who started the job", async ({ playwright }) => {
86
+ const owner = await session(playwright);
87
+ const other = await session(playwright);
88
+ const streams: ProgressStream[] = [];
89
+ try {
90
+ test.skip(!(await signedIn(owner)) || !(await signedIn(other)), "requires the auth module");
91
+ const ownerStream = await ProgressStream.open(owner);
92
+ const otherStream = await ProgressStream.open(other);
93
+ streams.push(ownerStream, otherStream);
94
+
95
+ const started = await owner.post("/api/progress", { data: { steps: 2 } });
96
+ test.skip(started.status() >= 500, "redis not reachable");
97
+ expect(started.status()).toBe(201);
98
+ const { jobId } = await started.json() as { jobId: string };
99
+ await ownerStream.until(completed(jobId));
100
+
101
+ // Every event the owner's job produced was published before the other user's
102
+ // job started, so it would precede that job's completion on the other stream.
103
+ const otherStarted = await other.post("/api/progress", { data: { steps: 1 } });
104
+ expect(otherStarted.status()).toBe(201);
105
+ const { jobId: otherJobId } = await otherStarted.json() as { jobId: string };
106
+ expect(await otherStream.until(completed(otherJobId))).not.toContain(JSON.stringify(jobId));
107
+ } finally {
108
+ for (const stream of streams) stream.close();
109
+ await owner.dispose();
110
+ await other.dispose();
111
+ }
112
+ });
@@ -12,8 +12,8 @@
12
12
  ],
13
13
  "instructions": [
14
14
  "Configure the selected jobs and events providers before starting the API.",
15
- "Watch progress: curl -N localhost:5002/events/stream",
16
- "Start a job: curl -XPOST localhost:5002/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
17
- "The stream shows job-progress events 20 -> 40 -> ... -> 100 through the selected events provider."
15
+ "Watch progress as a signed-in user: curl -N -b cookies.txt 'localhost:5002/events/stream?topics=job-progress'",
16
+ "Start a job as the same user: curl -b cookies.txt -XPOST localhost:5002/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
17
+ "The stream shows job-progress events 20 -> 40 -> ... -> 100, delivered only to the user who started the job."
18
18
  ]
19
19
  }