@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -0,0 +1,80 @@
1
+ import { afterAll, describe, expect, it } from "bun:test";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { ReadinessService } from "../health/readiness.service";
4
+ import { S3ObjectStore } from "./s3-object.store";
5
+ import { StorageService } from "./storage.service";
6
+
7
+ // Runs against the bucket in the S3_* settings, like the storage API spec.
8
+ const integration = process.env.S3_ENDPOINT ? describe : describe.skip;
9
+
10
+ integration("S3ObjectStore against a live bucket", () => {
11
+ const storage = new StorageService(new ReadinessService());
12
+ const store = new S3ObjectStore(storage);
13
+ const prefix = `podokit-test/${randomUUID()}/`;
14
+ const created: string[] = [];
15
+
16
+ async function put(name: string, content: Uint8Array | string, contentType?: string): Promise<string> {
17
+ const key = `${prefix}${name}`;
18
+ created.push(key);
19
+ await store.put(key, content, contentType ? { contentType } : {});
20
+ return key;
21
+ }
22
+
23
+ afterAll(async () => {
24
+ await Promise.all(created.map((key) => storage.delete(key)));
25
+ storage.close();
26
+ });
27
+
28
+ it("describes an object and streams exactly its content", async () => {
29
+ const content = randomBytes(3 * 1024 * 1024);
30
+ const key = await put("object.bin", content, "application/octet-stream");
31
+
32
+ const info = await store.stat(key);
33
+ expect(info).toMatchObject({ key, size: content.byteLength, contentType: "application/octet-stream" });
34
+ expect(info?.etag).toMatch(/^"[^"]+"$/);
35
+ expect(info?.lastModified).toBeInstanceOf(Date);
36
+
37
+ const object = await store.get(key);
38
+ if (!object) throw new Error("expected the stored object");
39
+ expect(object.etag).toBe(info?.etag ?? "");
40
+ const streamed = Buffer.from(await new Response(object.body).arrayBuffer());
41
+ expect(streamed.equals(content)).toBe(true);
42
+ });
43
+
44
+ it("lists keys a page at a time", async () => {
45
+ const names = ["list/a.txt", "list/b.txt", "list/c/d.txt", "list/e.txt", "listed.txt"];
46
+ for (const name of names) await put(name, name);
47
+
48
+ const pages: string[][] = [];
49
+ let cursor: string | undefined;
50
+ do {
51
+ const page = await store.list(`${prefix}list/`, { limit: 2, ...(cursor ? { cursor } : {}) });
52
+ pages.push(page.keys.map((key) => key.slice(prefix.length)));
53
+ cursor = page.nextCursor;
54
+ } while (cursor !== undefined);
55
+
56
+ expect(pages).toEqual([["list/a.txt", "list/b.txt"], ["list/c/d.txt", "list/e.txt"]]);
57
+ expect((await store.list(`${prefix}list`)).keys).toHaveLength(names.length);
58
+ await expect(store.list(prefix, { limit: 0 })).rejects.toThrow("from 1 to 1000");
59
+ });
60
+
61
+ it("reports a missing key as null and lets an unread body be dropped", async () => {
62
+ expect(await store.get(`${prefix}missing`)).toBeNull();
63
+ expect(await store.stat(`${prefix}missing`)).toBeNull();
64
+
65
+ const key = await put("unread.txt", "unread", "text/plain");
66
+ const object = await store.get(key);
67
+ if (!object) throw new Error("expected the stored object");
68
+ await object.body.cancel();
69
+ expect(object.size).toBe(6);
70
+ });
71
+
72
+ it("fails the stream rather than mix in content written after the metadata was read", async () => {
73
+ const key = await put("replaced.txt", "before", "text/plain");
74
+ const object = await store.get(key);
75
+ if (!object) throw new Error("expected the stored object");
76
+ await store.put(key, "after!", { contentType: "text/plain" });
77
+
78
+ await expect(new Response(object.body).text()).rejects.toThrow("changed while it was being read");
79
+ });
80
+ });
@@ -1,7 +1,10 @@
1
1
  import type {
2
2
  DownloadUrlOptions,
3
+ ListObjectsOptions,
3
4
  ObjectBody,
4
5
  ObjectData,
6
+ ObjectInfo,
7
+ ObjectPage,
5
8
  ObjectStore,
6
9
  PutObjectOptions,
7
10
  StoredObject,
@@ -16,10 +19,6 @@ async function bodyBytes(body: ObjectBody): Promise<Uint8Array> {
16
19
  return Buffer.concat(chunks);
17
20
  }
18
21
 
19
- async function* oneChunk(body: Uint8Array): AsyncGenerator<Uint8Array> {
20
- yield body;
21
- }
22
-
23
22
  export class S3ObjectStore implements ObjectStore {
24
23
  constructor(private readonly storage: StorageService) {}
25
24
 
@@ -29,9 +28,16 @@ export class S3ObjectStore implements ObjectStore {
29
28
  return { key, size: bytes.byteLength, ...options };
30
29
  }
31
30
 
32
- async get(key: string): Promise<ObjectData> {
33
- const bytes = await this.storage.get(key);
34
- return { key, size: bytes.byteLength, body: oneChunk(bytes) };
31
+ get(key: string): Promise<ObjectData | null> {
32
+ return this.storage.open(key);
33
+ }
34
+
35
+ stat(key: string): Promise<ObjectInfo | null> {
36
+ return this.storage.stat(key);
37
+ }
38
+
39
+ list(prefix: string, options?: ListObjectsOptions): Promise<ObjectPage> {
40
+ return this.storage.list(prefix, options);
35
41
  }
36
42
 
37
43
  exists(key: string): Promise<boolean> {
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import { Elysia } from "elysia";
4
+ import type { RequestIdentity } from "../core/services";
5
+ import { storageDemoRoutes, type DemoStorage } from "./storage.module";
6
+
7
+ interface MemoryStorage extends DemoStorage {
8
+ objects: Map<string, string>;
9
+ presigned: string[];
10
+ }
11
+
12
+ function memoryStorage(): MemoryStorage {
13
+ const objects = new Map<string, string>();
14
+ const presigned: string[] = [];
15
+ return {
16
+ objects,
17
+ presigned,
18
+ put: async (key, body) => {
19
+ if (typeof body === "string") objects.set(key, body);
20
+ else if (body instanceof Blob) objects.set(key, await body.text());
21
+ else objects.set(key, Buffer.from(body).toString("utf8"));
22
+ },
23
+ open: async (key) => {
24
+ const content = objects.get(key);
25
+ if (content === undefined) return null;
26
+ const bytes = Buffer.from(content);
27
+ return {
28
+ key,
29
+ size: bytes.byteLength,
30
+ contentType: "text/plain",
31
+ etag: `"${bytes.toString("hex")}"`,
32
+ lastModified: new Date("2026-01-02T03:04:05Z"),
33
+ body: new Blob([bytes]).stream(),
34
+ };
35
+ },
36
+ presignedGetUrl: async (key) => {
37
+ presigned.push(key);
38
+ return `https://storage.example.com/${key}?signature=test`;
39
+ },
40
+ };
41
+ }
42
+
43
+ function caller(userId: string | undefined, admin = false): RequestIdentity {
44
+ return {
45
+ userId: async () => userId,
46
+ isAdmin: async () => admin,
47
+ roles: async () => (admin ? ["admin"] : []),
48
+ activeOrganizationId: async () => undefined,
49
+ };
50
+ }
51
+
52
+ async function call(
53
+ storage: DemoStorage,
54
+ identity: RequestIdentity,
55
+ method: "GET" | "PUT",
56
+ path: string,
57
+ options: { content?: string; headers?: Record<string, string> } = {},
58
+ ): Promise<Response> {
59
+ const app = new Elysia()
60
+ .onError(({ code, error, set }) => {
61
+ if (error instanceof AppException) {
62
+ set.status = error.statusCode;
63
+ return { code: error.code };
64
+ }
65
+ if (code === "VALIDATION") {
66
+ set.status = 400;
67
+ return { code: "VALIDATION_ERROR" };
68
+ }
69
+ if (code === "NOT_FOUND") {
70
+ set.status = 404;
71
+ return { code: "NOT_FOUND" };
72
+ }
73
+ return undefined;
74
+ })
75
+ .use(storageDemoRoutes(storage, identity));
76
+ return app.handle(new Request(`http://localhost${path}`, {
77
+ method,
78
+ headers: {
79
+ ...(options.content === undefined ? {} : { "content-type": "application/json" }),
80
+ ...options.headers,
81
+ },
82
+ ...(options.content === undefined ? {} : { body: JSON.stringify({ content: options.content }) }),
83
+ }));
84
+ }
85
+
86
+ async function reply(response: Promise<Response>): Promise<{ status: number; body: unknown }> {
87
+ const resolved = await response;
88
+ return { status: resolved.status, body: await resolved.json() };
89
+ }
90
+
91
+ describe("object storage demo routes", () => {
92
+ it("require a signed-in administrator", async () => {
93
+ const storage = memoryStorage();
94
+
95
+ expect(await reply(call(storage, caller(undefined), "PUT", "/storage/hello", { content: "x" }))).toEqual({
96
+ status: 401,
97
+ body: { code: "AUTH_REQUIRED" },
98
+ });
99
+ for (const [method, path] of [
100
+ ["PUT", "/storage/hello"],
101
+ ["GET", "/storage/hello"],
102
+ ["GET", "/storage/hello/presigned"],
103
+ ] as const) {
104
+ const response = call(storage, caller("user-1"), method, path, method === "PUT" ? { content: "x" } : {});
105
+ expect(await reply(response)).toEqual({ status: 403, body: { code: "ADMIN_REQUIRED" } });
106
+ }
107
+ expect(storage.objects.size).toBe(0);
108
+ expect(storage.presigned).toEqual([]);
109
+ });
110
+
111
+ it("stores, streams, and presigns objects under the demo prefix for administrators", async () => {
112
+ const storage = memoryStorage();
113
+ const admin = caller("admin-1", true);
114
+
115
+ expect(await reply(call(storage, admin, "PUT", "/storage/hello", { content: "hi" }))).toEqual({
116
+ status: 200,
117
+ body: { key: "hello" },
118
+ });
119
+ expect([...storage.objects.keys()]).toEqual(["demo/hello"]);
120
+
121
+ const read = await call(storage, admin, "GET", "/storage/hello");
122
+ expect(read.status).toBe(200);
123
+ expect(read.headers.get("content-type")).toBe("text/plain");
124
+ expect(read.headers.get("content-length")).toBe("2");
125
+ expect(read.headers.get("x-content-type-options")).toBe("nosniff");
126
+ const etag = read.headers.get("etag");
127
+ expect(etag).toBe("\"6869\"");
128
+ expect(await read.text()).toBe("hi");
129
+
130
+ const revalidated = await call(storage, admin, "GET", "/storage/hello", {
131
+ headers: { "if-none-match": etag ?? "" },
132
+ });
133
+ expect(revalidated.status).toBe(304);
134
+ expect(await revalidated.text()).toBe("");
135
+
136
+ const presigned = await call(storage, admin, "GET", "/storage/hello/presigned");
137
+ expect(presigned.status).toBe(200);
138
+ expect(storage.presigned).toEqual(["demo/hello"]);
139
+ });
140
+
141
+ it("reports a missing demo object as not found", async () => {
142
+ const response = call(memoryStorage(), caller("admin-1", true), "GET", "/storage/missing");
143
+ expect(await reply(response)).toEqual({ status: 404, body: { code: "STORAGE_OBJECT_NOT_FOUND" } });
144
+ });
145
+
146
+ it("rejects keys that would leave the demo prefix", async () => {
147
+ const storage = memoryStorage();
148
+ const admin = caller("admin-1", true);
149
+
150
+ for (const key of ["site%2Ffavicon", "profile-images%2Fa.png", "..%2Fsite%2Ffavicon", ".podokit", "%2E%2E"]) {
151
+ const response = await call(storage, admin, "PUT", `/storage/${key}`, { content: "x" });
152
+ expect([400, 404]).toContain(response.status);
153
+ }
154
+ expect(storage.objects.size).toBe(0);
155
+ });
156
+ });
@@ -0,0 +1,38 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { t } from "elysia";
3
+ import type { RequestIdentity } from "../core/services";
4
+
5
+ /**
6
+ * The demo routes only reach objects under this prefix, so even an
7
+ * administrator cannot overwrite keys owned by other features such as
8
+ * uploads, profile images, or the site favicon.
9
+ */
10
+ export const DEMO_KEY_PREFIX = "demo/";
11
+
12
+ /**
13
+ * One path segment. Elysia decodes `%2F` inside `:key`, so the pattern rejects
14
+ * slashes and leading dots instead of trusting the router.
15
+ */
16
+ export const demoKeyParams = t.Object({
17
+ key: t.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
18
+ });
19
+
20
+ export function demoObjectKey(name: string): string {
21
+ return `${DEMO_KEY_PREFIX}${name}`;
22
+ }
23
+
24
+ export function storageObjectNotFound(): AppException {
25
+ return new AppException("STORAGE_OBJECT_NOT_FOUND", "Object not found", 404);
26
+ }
27
+
28
+ export async function requireStorageAdmin(
29
+ identity: RequestIdentity,
30
+ request: Request,
31
+ ): Promise<void> {
32
+ if (!(await identity.userId(request))) {
33
+ throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
34
+ }
35
+ if (!(await identity.isAdmin(request))) {
36
+ throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
37
+ }
38
+ }
@@ -1,44 +1,62 @@
1
1
  import { Elysia, t } from "elysia";
2
- import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
2
+ import { OBJECT_STORAGE, objectResponse } from "@podosoft/podokit-runtime";
3
3
  import { PROVIDERS } from "../config/providers";
4
4
  import {
5
5
  type AppPlugin,
6
+ type ElysiaPlugin,
6
7
  type PodokitModule,
7
8
  READINESS,
9
+ REQUEST_IDENTITY,
10
+ type RequestIdentity,
8
11
  type ServiceKey,
9
12
  } from "../core/services";
10
13
  import { StorageService } from "./storage.service";
11
14
  import { S3ObjectStore } from "./s3-object.store";
15
+ import {
16
+ demoKeyParams,
17
+ demoObjectKey,
18
+ storageObjectNotFound,
19
+ requireStorageAdmin,
20
+ } from "./storage-demo";
12
21
 
13
22
  export const STORAGE = Symbol("storage") as ServiceKey<StorageService>;
14
23
 
15
- const storagePlugin: AppPlugin = ({ services }) => {
16
- if (PROVIDERS["object-storage"] !== "s3") {
17
- return new Elysia({ name: "podokit.storage-s3.inactive" });
18
- }
19
- const storage = services.resolve(STORAGE);
24
+ export type DemoStorage = Pick<StorageService, "put" | "open" | "presignedGetUrl">;
25
+
26
+ export function storageDemoRoutes(storage: DemoStorage, identity: RequestIdentity): ElysiaPlugin {
20
27
  return new Elysia({ name: "podokit.storage" })
21
- .put("/storage/:key", async ({ params, body }) => {
22
- await storage.put(params.key, body.content, "text/plain");
28
+ .put("/storage/:key", async ({ request, params, body }) => {
29
+ await requireStorageAdmin(identity, request);
30
+ await storage.put(demoObjectKey(params.key), body.content, "text/plain");
23
31
  return { key: params.key };
24
32
  }, {
25
- params: t.Object({ key: t.String({ minLength: 1 }) }),
33
+ params: demoKeyParams,
26
34
  body: t.Object({ content: t.String() }),
27
- detail: { tags: ["storage"], summary: "Store an object" },
35
+ detail: { tags: ["storage"], summary: "Store a demo object (administrators only)" },
28
36
  })
29
- .get("/storage/:key/presigned", async ({ params }) => ({
30
- url: await storage.presignedGetUrl(params.key),
31
- }), {
32
- params: t.Object({ key: t.String({ minLength: 1 }) }),
33
- detail: { tags: ["storage"], summary: "Presign an object download" },
37
+ .get("/storage/:key/presigned", async ({ request, params }) => {
38
+ await requireStorageAdmin(identity, request);
39
+ return { url: await storage.presignedGetUrl(demoObjectKey(params.key)) };
40
+ }, {
41
+ params: demoKeyParams,
42
+ detail: { tags: ["storage"], summary: "Presign a demo object download (administrators only)" },
34
43
  })
35
- .get("/storage/:key", async ({ params }) => ({
36
- key: params.key,
37
- content: (await storage.get(params.key)).toString("utf8"),
38
- }), {
39
- params: t.Object({ key: t.String({ minLength: 1 }) }),
40
- detail: { tags: ["storage"], summary: "Read an object" },
44
+ .get("/storage/:key", async ({ request, params }) => {
45
+ await requireStorageAdmin(identity, request);
46
+ const object = await storage.open(demoObjectKey(params.key));
47
+ if (!object) throw storageObjectNotFound();
48
+ return objectResponse(request, object, { headers: { "x-content-type-options": "nosniff" } });
49
+ }, {
50
+ params: demoKeyParams,
51
+ detail: { tags: ["storage"], summary: "Read a demo object (administrators only)" },
41
52
  });
53
+ }
54
+
55
+ const storagePlugin: AppPlugin = ({ services }) => {
56
+ if (PROVIDERS["object-storage"] !== "s3") {
57
+ return new Elysia({ name: "podokit.storage-s3.inactive" });
58
+ }
59
+ return storageDemoRoutes(services.resolve(STORAGE), services.resolve(REQUEST_IDENTITY));
42
60
  };
43
61
 
44
62
  export const storageModule: PodokitModule = {
@@ -1,7 +1,70 @@
1
- import { S3Client } from "bun";
1
+ import { S3Client, type S3Stats } from "bun";
2
+ import {
3
+ objectListLimit,
4
+ type ListObjectsOptions,
5
+ type ObjectData,
6
+ type ObjectInfo,
7
+ type ObjectPage,
8
+ } from "@podosoft/podokit-runtime";
2
9
  import { ReadinessService } from "../health/readiness.service";
3
10
  import { storageSettings } from "./storage.config";
4
11
 
12
+ function isMissingObject(error: unknown): boolean {
13
+ return error instanceof Error && "code" in error && error.code === "NoSuchKey";
14
+ }
15
+
16
+ function entityTag(etag: string): string {
17
+ return etag.startsWith("\"") || etag.startsWith("W/\"") ? etag : `"${etag}"`;
18
+ }
19
+
20
+ function objectInfo(key: string, stats: S3Stats): ObjectInfo {
21
+ return {
22
+ key,
23
+ size: stats.size,
24
+ ...(stats.type ? { contentType: stats.type } : {}),
25
+ etag: entityTag(stats.etag),
26
+ lastModified: stats.lastModified,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Downloads on the first read, so an unread body costs no request. Bun's
32
+ * `S3File.stream()` reads ahead without backpressure and would hold the rest of
33
+ * the object for a slow client; a presigned GET through fetch is paced by the
34
+ * socket instead, and If-Match keeps it on the version that was described.
35
+ */
36
+ function download(client: S3Client, key: string, etag: string): ReadableStream<Uint8Array> {
37
+ const abort = new AbortController();
38
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
39
+ return new ReadableStream<Uint8Array>({
40
+ async pull(controller): Promise<void> {
41
+ if (!reader) {
42
+ const response = await fetch(client.presign(key, { expiresIn: 60 }), {
43
+ headers: { "if-match": etag },
44
+ decompress: false,
45
+ signal: abort.signal,
46
+ });
47
+ if (!response.ok || !response.body) {
48
+ await response.body?.cancel();
49
+ throw new Error(
50
+ response.status === 404 || response.status === 412
51
+ ? `Object ${key} changed while it was being read`
52
+ : `Object ${key} download failed with status ${response.status}`,
53
+ );
54
+ }
55
+ reader = response.body.getReader();
56
+ }
57
+ const next = await reader.read();
58
+ if (next.done) controller.close();
59
+ else controller.enqueue(next.value);
60
+ },
61
+ async cancel(reason): Promise<void> {
62
+ abort.abort(reason);
63
+ await reader?.cancel(reason).catch(() => undefined);
64
+ },
65
+ }, { highWaterMark: 0 });
66
+ }
67
+
5
68
  export class StorageService {
6
69
  private readonly client: S3Client;
7
70
  private unregisterReadiness?: () => void;
@@ -25,8 +88,44 @@ export class StorageService {
25
88
  await this.client.file(key).write(body, contentType ? { type: contentType } : undefined);
26
89
  }
27
90
 
91
+ /** Reads a whole object into memory. Use `open()` for anything that can be large. */
28
92
  async get(key: string): Promise<Buffer> {
29
- return Buffer.from(await this.client.file(key).bytes());
93
+ const bytes = await this.client.file(key).bytes();
94
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
95
+ }
96
+
97
+ /** Object metadata, or null when the key does not exist. */
98
+ async stat(key: string): Promise<ObjectInfo | null> {
99
+ try {
100
+ return objectInfo(key, await this.client.file(key).stat());
101
+ } catch (error) {
102
+ if (isMissingObject(error)) return null;
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Metadata plus a streamed body, or null when the key does not exist. The
109
+ * metadata request comes first because a missing key would otherwise only
110
+ * fail the stream, after a response has already started.
111
+ */
112
+ async open(key: string): Promise<ObjectData | null> {
113
+ const info = await this.stat(key);
114
+ if (!info) return null;
115
+ return { ...info, body: download(this.client, key, info.etag) };
116
+ }
117
+
118
+ /** One page of the keys under `prefix`; the bucket is never listed in full. */
119
+ async list(prefix: string, options: ListObjectsOptions = {}): Promise<ObjectPage> {
120
+ const page = await this.client.list({
121
+ prefix,
122
+ maxKeys: objectListLimit(options),
123
+ ...(options.cursor ? { continuationToken: options.cursor } : {}),
124
+ });
125
+ const keys = (page.contents ?? []).map((object) => object.key);
126
+ return page.isTruncated && page.nextContinuationToken
127
+ ? { keys, nextCursor: page.nextContinuationToken }
128
+ : { keys };
30
129
  }
31
130
 
32
131
  async exists(key: string): Promise<boolean> {
@@ -1,19 +1,50 @@
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
+ // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
6
+ // spec also compiles in apps that do not install that module.
7
+ const admin = {
8
+ email: "admin@example.com",
9
+ password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
10
+ };
5
11
 
6
- async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]): Promise<import("@playwright/test").APIRequestContext> {
7
- const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
12
+ type Playwright = PlaywrightWorkerArgs["playwright"];
13
+
14
+ function uniqueKey(): string {
15
+ return `obj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
16
+ }
17
+
18
+ function newContext(playwright: Playwright): Promise<APIRequestContext> {
19
+ return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
20
+ }
21
+
22
+ async function sessionRoles(ctx: APIRequestContext): Promise<string[] | undefined> {
23
+ const response = await ctx.get("/api/auth/get-session");
24
+ if (!response.ok()) return undefined;
25
+ const session = await response.json() as { user?: { role?: string | null } } | null;
26
+ if (!session?.user) return undefined;
27
+ return (session.user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
28
+ }
29
+
30
+ /** Resolves false when the auth module is missing or sign-up does not start a session. */
31
+ async function signUp(ctx: APIRequestContext): Promise<boolean> {
8
32
  const email = `storage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
- await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } }).catch(() => undefined);
10
- return ctx;
33
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } });
34
+ return (await sessionRoles(ctx)) !== undefined;
35
+ }
36
+
37
+ /** Resolves false when no administrator session is available. */
38
+ async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
39
+ const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
40
+ return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
11
41
  }
12
42
 
13
43
  test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
14
- const ctx = await session(playwright);
15
- const key = `obj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
44
+ const ctx = await newContext(playwright);
45
+ const key = uniqueKey();
16
46
  try {
47
+ test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
17
48
  const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
18
49
  test.skip(!process.env.S3_ENDPOINT && put.status() >= 500, "optional S3-compatible service not configured");
19
50
  expect(put.ok()).toBeTruthy();
@@ -21,12 +52,20 @@ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
21
52
 
22
53
  const read = await ctx.get(`/api/storage/${key}`);
23
54
  expect(read.ok()).toBeTruthy();
24
- expect(await read.json()).toMatchObject({ key, content: "hello world" });
55
+ expect(read.headers()["content-type"]).toContain("text/plain");
56
+ expect(read.headers()["content-length"]).toBe("11");
57
+ expect(read.headers()["last-modified"]).toBeTruthy();
58
+ const etag = read.headers()["etag"];
59
+ expect(etag).toMatch(/^"[^"]+"$/);
60
+ expect(await read.text()).toBe("hello world");
61
+
62
+ const revalidated = await ctx.get(`/api/storage/${key}`, { headers: { "if-none-match": etag ?? "" } });
63
+ expect(revalidated.status()).toBe(304);
25
64
 
26
65
  const presigned = await ctx.get(`/api/storage/${key}/presigned`);
27
66
  expect(presigned.ok()).toBeTruthy();
28
67
  const pre = await presigned.json() as { url: string };
29
- expect(new URL(pre.url).pathname).toContain(key);
68
+ expect(new URL(pre.url, base).pathname).toContain(`demo/${key}`);
30
69
  // The URL is signed for the API's S3 endpoint. A containerized dev stack signs an
31
70
  // in-network host, so download it only when the runner was given that endpoint too.
32
71
  if (process.env.S3_ENDPOINT) {
@@ -38,3 +77,39 @@ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
38
77
  await ctx.dispose();
39
78
  }
40
79
  });
80
+
81
+ test("object storage demo routes are limited to administrators", async ({ playwright }) => {
82
+ const anonymous = await newContext(playwright);
83
+ const user = await newContext(playwright);
84
+ const key = uniqueKey();
85
+ try {
86
+ test.skip(!(await signUp(user)), "requires the auth module");
87
+ const unauthenticated = await anonymous.put(`/api/storage/${key}`, { data: { content: "x" } });
88
+ expect(unauthenticated.status()).toBe(401);
89
+
90
+ for (const response of [
91
+ await user.put(`/api/storage/${key}`, { data: { content: "x" } }),
92
+ await user.get(`/api/storage/${key}`),
93
+ await user.get(`/api/storage/${key}/presigned`),
94
+ ]) {
95
+ expect(response.status()).toBe(403);
96
+ expect(await response.json()).toMatchObject({ error: { code: "ADMIN_REQUIRED" } });
97
+ }
98
+ } finally {
99
+ await anonymous.dispose();
100
+ await user.dispose();
101
+ }
102
+ });
103
+
104
+ test("object storage demo keys cannot reach other features' objects", async ({ playwright }) => {
105
+ const ctx = await newContext(playwright);
106
+ try {
107
+ test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
108
+ for (const key of ["site%2Ffavicon", "profile-images%2Fx.png", "..%2Fsite%2Ffavicon", ".podokit"]) {
109
+ const response = await ctx.put(`/api/storage/${key}`, { data: { content: "x" } });
110
+ expect([400, 404], `PUT /api/storage/${key}`).toContain(response.status());
111
+ }
112
+ } finally {
113
+ await ctx.dispose();
114
+ }
115
+ });
@@ -7,6 +7,9 @@
7
7
  "requiredProjectFiles": [
8
8
  "apps/api/src/health/readiness.service.ts"
9
9
  ],
10
+ "requiredProjectContents": {
11
+ "apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
12
+ },
10
13
  "targetApp": "api",
11
14
  "env": [
12
15
  "# object storage — STORAGE_PROVIDER: minio | aws",
@@ -24,9 +27,11 @@
24
27
  ],
25
28
  "instructions": [
26
29
  "Start Silo for local dev: docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
27
- "Store an object: curl -XPUT localhost:5002/storage/hello -H 'content-type: application/json' -d '{\"content\":\"hi\"}'",
28
- "Read it back: curl localhost:5002/storage/hello",
29
- "Presigned URL: curl localhost:5002/storage/hello/presigned",
30
+ "The /storage demo routes need the auth module and an administrator session, and only reach keys under demo/.",
31
+ "Sign in as an administrator: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-in/email -H 'content-type: application/json' -d '{\"email\":\"admin@example.com\",\"password\":\"<password>\"}'",
32
+ "Store an object: curl -b cookies.txt -XPUT localhost:5002/storage/hello -H 'content-type: application/json' -d '{\"content\":\"hi\"}'",
33
+ "Read it back: curl -b cookies.txt localhost:5002/storage/hello",
34
+ "Presigned URL: curl -b cookies.txt localhost:5002/storage/hello/presigned",
30
35
  "For AWS S3: set STORAGE_PROVIDER=aws, remove S3_ENDPOINT, set S3_FORCE_PATH_STYLE=false and real credentials/bucket."
31
36
  ]
32
37
  }