@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
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { uploadAccess, uploadKey } from "./upload-keys";
3
+
4
+ const id = "0b5f6b7e-8c1d-4d6e-9f00-112233445566";
5
+
6
+ describe("uploadKey", () => {
7
+ it("places a signed-in user's upload under their id", () => {
8
+ expect(uploadKey("user_1", "my photo.png", id)).toBe(`uploads/user_1/${id}-my_photo.png`);
9
+ });
10
+
11
+ it("keeps the unowned format when there is no signed-in user", () => {
12
+ expect(uploadKey(undefined, "a.txt", id)).toBe(`uploads/${id}-a.txt`);
13
+ });
14
+
15
+ it("keeps file names to one bounded segment", () => {
16
+ expect(uploadKey("u", "../../etc/passwd", id)).toBe(`uploads/u/${id}-.._.._etc_passwd`);
17
+ expect(uploadKey("u", `${"x".repeat(300)}.png`, id)).toBe(`uploads/u/${id}-${"x".repeat(124)}.png`);
18
+ });
19
+
20
+ it("refuses a user id that is not a safe key segment", () => {
21
+ expect(() => uploadKey("../admin", "a.txt", id)).toThrow("upload key segment");
22
+ });
23
+ });
24
+
25
+ describe("uploadAccess", () => {
26
+ const owner = { userId: "user_1", admin: false };
27
+ const other = { userId: "user_2", admin: false };
28
+ const admin = { userId: "admin_1", admin: true };
29
+ const anonymous = { userId: undefined, admin: false };
30
+
31
+ it("lets owners and administrators read owned uploads", () => {
32
+ const key = `uploads/user_1/${id}-a.txt`;
33
+ expect(uploadAccess(key, owner)).toBe("allowed");
34
+ expect(uploadAccess(key, admin)).toBe("allowed");
35
+ expect(uploadAccess(key, other)).toBe("forbidden");
36
+ expect(uploadAccess(key, anonymous)).toBe("forbidden");
37
+ });
38
+
39
+ it("keeps unowned uploads from signed-in users who are not administrators", () => {
40
+ const key = `uploads/${id}-a.txt`;
41
+ expect(uploadAccess(key, owner)).toBe("forbidden");
42
+ expect(uploadAccess(key, admin)).toBe("allowed");
43
+ expect(uploadAccess(key, anonymous)).toBe("allowed");
44
+ });
45
+
46
+ it("does not presign keys owned by other features", () => {
47
+ for (const key of [
48
+ "site/favicon",
49
+ "profile-images/a.png",
50
+ "demo/hello",
51
+ "uploads/",
52
+ "uploads/user_1/",
53
+ "uploads/../site/favicon",
54
+ "uploads/user_1/../user_2/a.txt",
55
+ "uploads/user_1/nested/a.txt",
56
+ ]) {
57
+ expect(uploadAccess(key, admin)).toBe("not-found");
58
+ }
59
+ });
60
+ });
@@ -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
+ }