@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,159 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import type {
5
+ DownloadUrlOptions,
6
+ ListObjectsOptions,
7
+ ObjectBody,
8
+ ObjectData,
9
+ ObjectInfo,
10
+ ObjectPage,
11
+ ObjectStore,
12
+ PutObjectOptions,
13
+ StoredObject,
14
+ } from "@podosoft/podokit-runtime";
15
+
16
+ const KEY_BYTES = 32;
17
+ const DEFAULT_EXPIRES_IN_SECONDS = 3600;
18
+ const MAX_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
19
+ const EXPIRES = /^\d{1,12}$/;
20
+
21
+ export interface DownloadUrlSignature {
22
+ expires: string;
23
+ signature: string;
24
+ }
25
+
26
+ function errorCode(error: unknown): string | undefined {
27
+ return error instanceof Error && "code" in error && typeof error.code === "string"
28
+ ? error.code
29
+ : undefined;
30
+ }
31
+
32
+ async function readKey(path: string): Promise<Buffer> {
33
+ const key = await readFile(path);
34
+ if (key.byteLength !== KEY_BYTES) throw new Error(`Download URL key is corrupt: ${path}`);
35
+ return key;
36
+ }
37
+
38
+ /**
39
+ * Creates the key once and shares it with every later process. `link` fails
40
+ * when another process won the race, so a reader never sees a partial file.
41
+ */
42
+ async function loadOrCreateKey(path: string): Promise<Buffer> {
43
+ try {
44
+ return await readKey(path);
45
+ } catch (error) {
46
+ if (errorCode(error) !== "ENOENT") throw error;
47
+ }
48
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
49
+ const temporary = `${path}.${randomUUID()}.tmp`;
50
+ await writeFile(temporary, randomBytes(KEY_BYTES), { mode: 0o600, flag: "wx" });
51
+ try {
52
+ await link(temporary, path);
53
+ } catch (error) {
54
+ if (errorCode(error) !== "EEXIST") throw error;
55
+ } finally {
56
+ await rm(temporary, { force: true });
57
+ }
58
+ return readKey(path);
59
+ }
60
+
61
+ /**
62
+ * Signs local download URLs so a URL works like an S3 presigned URL: it names
63
+ * one object, expires, and cannot be altered to reach another key.
64
+ */
65
+ export class DownloadUrlSigner {
66
+ private key?: Promise<Buffer>;
67
+
68
+ constructor(
69
+ private readonly keyFile: string,
70
+ private readonly now: () => number = Date.now,
71
+ ) {}
72
+
73
+ async sign(
74
+ objectKey: string,
75
+ expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS,
76
+ ): Promise<DownloadUrlSignature> {
77
+ const lifetime = Math.min(
78
+ Math.max(Math.floor(expiresInSeconds), 1),
79
+ MAX_EXPIRES_IN_SECONDS,
80
+ );
81
+ const expires = String(Math.floor(this.now() / 1000) + lifetime);
82
+ const signature = (await this.digest(objectKey, expires)).toString("base64url");
83
+ return { expires, signature };
84
+ }
85
+
86
+ async verify(
87
+ objectKey: string,
88
+ expires: string | undefined,
89
+ signature: string | undefined,
90
+ ): Promise<boolean> {
91
+ if (!expires || !signature || !EXPIRES.test(expires)) return false;
92
+ if (Number(expires) * 1000 <= this.now()) return false;
93
+ const expected = await this.digest(objectKey, expires);
94
+ const provided = Buffer.from(signature, "base64url");
95
+ return provided.byteLength === expected.byteLength && timingSafeEqual(provided, expected);
96
+ }
97
+
98
+ private async digest(objectKey: string, expires: string): Promise<Buffer> {
99
+ this.key ??= loadOrCreateKey(this.keyFile);
100
+ return createHmac("sha256", await this.key)
101
+ .update(`podokit-local-download-v1\n${expires}\n${objectKey}`, "utf8")
102
+ .digest();
103
+ }
104
+ }
105
+
106
+ /** Reverses the per-segment encoding used in download URL paths. */
107
+ export function objectKeyFromPath(encoded: string): string | undefined {
108
+ try {
109
+ return encoded.split("/").map((segment) => decodeURIComponent(segment)).join("/");
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** Delegates storage to the local store and adds a signature to download URLs. */
116
+ export class SignedDownloadStore implements ObjectStore {
117
+ constructor(
118
+ private readonly store: ObjectStore,
119
+ private readonly signer: DownloadUrlSigner,
120
+ private readonly baseUrl: string,
121
+ ) {}
122
+
123
+ put(key: string, body: ObjectBody, options?: PutObjectOptions): Promise<StoredObject> {
124
+ return this.store.put(key, body, options);
125
+ }
126
+
127
+ get(key: string): Promise<ObjectData | null> {
128
+ return this.store.get(key);
129
+ }
130
+
131
+ stat(key: string): Promise<ObjectInfo | null> {
132
+ return this.store.stat(key);
133
+ }
134
+
135
+ list(prefix: string, options?: ListObjectsOptions): Promise<ObjectPage> {
136
+ return this.store.list(prefix, options);
137
+ }
138
+
139
+ exists(key: string): Promise<boolean> {
140
+ return this.store.exists(key);
141
+ }
142
+
143
+ delete(key: string): Promise<void> {
144
+ return this.store.delete(key);
145
+ }
146
+
147
+ async getDownloadUrl(key: string, options: DownloadUrlOptions = {}): Promise<string> {
148
+ const path = await this.store.getDownloadUrl(key);
149
+ const prefix = `${this.baseUrl}/`;
150
+ const objectKey = path.startsWith(prefix) ? objectKeyFromPath(path.slice(prefix.length)) : undefined;
151
+ if (objectKey === undefined) throw new Error("Local download URL does not match its base URL");
152
+ const { expires, signature } = await this.signer.sign(objectKey, options.expiresInSeconds);
153
+ return `${path}?${new URLSearchParams({ expires, signature }).toString()}`;
154
+ }
155
+
156
+ close(): void | Promise<void> {
157
+ return this.store.close();
158
+ }
159
+ }
@@ -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,8 +1,14 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync } from "node:fs";
2
3
  import { mkdtemp, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
6
+ import { AppException } from "@podosoft/podokit-contracts";
5
7
  import { LocalObjectStore } from "@podosoft/podokit-runtime";
8
+ import { Elysia } from "elysia";
9
+ import type { RequestIdentity } from "../core/services";
10
+ import { DownloadUrlSigner, SignedDownloadStore } from "./download-url";
11
+ import { DOWNLOAD_BASE_URL, downloadSafetyHeaders, localStorageRoutes } from "./storage-local.module";
6
12
 
7
13
  let root: string | undefined;
8
14
 
@@ -11,15 +17,177 @@ afterEach(async () => {
11
17
  root = undefined;
12
18
  });
13
19
 
20
+ interface Fixture {
21
+ directory: string;
22
+ storage: SignedDownloadStore;
23
+ signer: DownloadUrlSigner;
24
+ }
25
+
26
+ async function fixture(): Promise<Fixture> {
27
+ const directory = await mkdtemp(join(tmpdir(), "podokit-storage-"));
28
+ root = directory;
29
+ const signer = new DownloadUrlSigner(join(directory, ".podokit", "download-url.key"));
30
+ const storage = new SignedDownloadStore(
31
+ new LocalObjectStore({ rootDirectory: directory, publicBaseUrl: DOWNLOAD_BASE_URL }),
32
+ signer,
33
+ DOWNLOAD_BASE_URL,
34
+ );
35
+ return { directory, storage, signer };
36
+ }
37
+
38
+ function caller(userId: string | undefined, admin = false): RequestIdentity {
39
+ return {
40
+ userId: async () => userId,
41
+ isAdmin: async () => admin,
42
+ roles: async () => (admin ? ["admin"] : []),
43
+ activeOrganizationId: async () => undefined,
44
+ };
45
+ }
46
+
47
+ interface Reply {
48
+ status: number;
49
+ text: string;
50
+ contentType: string | null;
51
+ headers: Headers;
52
+ }
53
+
54
+ async function call(
55
+ { storage, signer }: Fixture,
56
+ identity: RequestIdentity,
57
+ method: "GET" | "PUT",
58
+ path: string,
59
+ content?: string,
60
+ headers: Record<string, string> = {},
61
+ ): Promise<Reply> {
62
+ const app = new Elysia()
63
+ .onError(({ code, error, set }) => {
64
+ if (error instanceof AppException) {
65
+ set.status = error.statusCode;
66
+ return { code: error.code };
67
+ }
68
+ if (code === "VALIDATION") {
69
+ set.status = 400;
70
+ return { code: "VALIDATION_ERROR" };
71
+ }
72
+ if (code === "NOT_FOUND") {
73
+ set.status = 404;
74
+ return { code: "NOT_FOUND" };
75
+ }
76
+ return undefined;
77
+ })
78
+ .use(localStorageRoutes(storage, signer, identity));
79
+ const response = await app.handle(new Request(`http://localhost${path}`, {
80
+ method,
81
+ headers: { ...(content === undefined ? {} : { "content-type": "application/json" }), ...headers },
82
+ ...(content === undefined ? {} : { body: JSON.stringify({ content }) }),
83
+ }));
84
+ return {
85
+ status: response.status,
86
+ text: await response.text(),
87
+ contentType: response.headers.get("content-type"),
88
+ headers: response.headers,
89
+ };
90
+ }
91
+
92
+ function code(reply: Reply): unknown {
93
+ return (JSON.parse(reply.text) as { code?: unknown }).code;
94
+ }
95
+
14
96
  describe("local object storage provider", () => {
15
97
  it("stores and reads an object without allowing traversal", async () => {
16
- root = await mkdtemp(join(tmpdir(), "podokit-storage-"));
17
- const storage = new LocalObjectStore({ rootDirectory: root });
98
+ const { storage } = await fixture();
18
99
  await storage.put("uploads/hello.txt", "hello", { contentType: "text/plain" });
19
100
  expect(await storage.exists("uploads/hello.txt")).toBe(true);
20
- expect(await storage.getDownloadUrl("uploads/hello.txt")).toBe(
21
- "/files/content/uploads/hello.txt",
101
+ expect(await storage.getDownloadUrl("uploads/hello.txt")).toStartWith(
102
+ "/files/content/uploads/hello.txt?expires=",
22
103
  );
23
104
  await expect(storage.put("../outside.txt", "no")).rejects.toThrow("safe relative path");
24
105
  });
106
+
107
+ it("serves an object only through an unaltered signed URL", async () => {
108
+ const local = await fixture();
109
+ const anonymous = caller(undefined);
110
+ await local.storage.put("uploads/a/one.txt", "one", { contentType: "text/plain" });
111
+ await local.storage.put("uploads/b/two.txt", "two", { contentType: "text/plain" });
112
+ const signed = await local.storage.getDownloadUrl("uploads/a/one.txt");
113
+
114
+ const download = await call(local, anonymous, "GET", signed);
115
+ expect(download).toMatchObject({ status: 200, text: "one", contentType: "text/plain" });
116
+ expect(download.headers.get("content-length")).toBe("3");
117
+ expect(download.headers.get("last-modified")).toBeTruthy();
118
+ const etag = download.headers.get("etag") ?? "";
119
+ expect(etag).toMatch(/^"[^"]+"$/);
120
+ const revalidated = await call(local, anonymous, "GET", signed, undefined, { "if-none-match": etag });
121
+ expect(revalidated).toMatchObject({ status: 304, text: "" });
122
+
123
+ const unsigned = await call(local, anonymous, "GET", "/files/content/uploads/a/one.txt");
124
+ expect(unsigned.status).toBe(403);
125
+ expect(code(unsigned)).toBe("DOWNLOAD_URL_INVALID");
126
+
127
+ const swapped = await call(local, anonymous, "GET", signed.replace("a/one.txt", "b/two.txt"));
128
+ expect(swapped.status).toBe(403);
129
+
130
+ await local.storage.delete("uploads/a/one.txt");
131
+ const missing = await call(local, anonymous, "GET", signed);
132
+ expect(missing.status).toBe(404);
133
+ expect(code(missing)).toBe("STORAGE_OBJECT_NOT_FOUND");
134
+ });
135
+
136
+ it("limits the demo routes to administrators", async () => {
137
+ const local = await fixture();
138
+
139
+ const anonymous = await call(local, caller(undefined), "PUT", "/storage/hello", "x");
140
+ expect(anonymous.status).toBe(401);
141
+ expect(code(anonymous)).toBe("AUTH_REQUIRED");
142
+ for (const [method, path] of [
143
+ ["PUT", "/storage/hello"],
144
+ ["GET", "/storage/hello"],
145
+ ["GET", "/storage/hello/presigned"],
146
+ ] as const) {
147
+ const reply = await call(local, caller("user-1"), method, path, method === "PUT" ? "x" : undefined);
148
+ expect(reply.status).toBe(403);
149
+ expect(code(reply)).toBe("ADMIN_REQUIRED");
150
+ }
151
+ expect(existsSync(join(local.directory, "demo"))).toBe(false);
152
+ });
153
+
154
+ it("keeps administrator demo objects under the demo prefix", async () => {
155
+ const local = await fixture();
156
+ const admin = caller("admin-1", true);
157
+
158
+ expect((await call(local, admin, "PUT", "/storage/hello", "hi")).status).toBe(200);
159
+ expect(existsSync(join(local.directory, "demo", "hello"))).toBe(true);
160
+ const read = await call(local, admin, "GET", "/storage/hello");
161
+ expect(read).toMatchObject({ status: 200, text: "hi", contentType: "text/plain" });
162
+ expect(read.headers.get("content-length")).toBe("2");
163
+ expect(read.headers.get("x-content-type-options")).toBe("nosniff");
164
+ const presigned = JSON.parse((await call(local, admin, "GET", "/storage/hello/presigned")).text) as {
165
+ url: string;
166
+ };
167
+ expect(presigned.url).toStartWith("/files/content/demo/hello?");
168
+ expect((await call(local, caller(undefined), "GET", presigned.url)).text).toBe("hi");
169
+
170
+ for (const key of ["site%2Ffavicon", "..%2Fsite%2Ffavicon", ".podokit", "%2E%2E"]) {
171
+ const reply = await call(local, admin, "PUT", `/storage/${key}`, "x");
172
+ expect([400, 404]).toContain(reply.status);
173
+ }
174
+ expect(existsSync(join(local.directory, "site"))).toBe(false);
175
+ expect((await call(local, admin, "GET", "/storage/missing")).status).toBe(404);
176
+ });
177
+ });
178
+
179
+ describe("local download safety headers", () => {
180
+ it("renders only script-free types inline", () => {
181
+ expect(downloadSafetyHeaders("image/png")["content-disposition"]).toBe("inline");
182
+ expect(downloadSafetyHeaders("text/plain; charset=utf-8")["content-disposition"]).toBe("inline");
183
+ for (const type of ["text/html", "image/svg+xml", "application/xml", "application/javascript", undefined]) {
184
+ expect(downloadSafetyHeaders(type)["content-disposition"]).toBe("attachment");
185
+ }
186
+ });
187
+
188
+ it("always forbids sniffing and script execution", () => {
189
+ const headers = downloadSafetyHeaders("image/png");
190
+ expect(headers["x-content-type-options"]).toBe("nosniff");
191
+ expect(headers["content-security-policy"]).toBe("default-src 'none'; sandbox");
192
+ });
25
193
  });
@@ -1,60 +1,137 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
1
2
  import {
2
3
  LocalObjectStore,
3
4
  OBJECT_STORAGE,
5
+ objectResponse,
4
6
  type ObjectData,
7
+ type ObjectStore,
5
8
  } from "@podosoft/podokit-runtime";
6
9
  import { Elysia, t } from "elysia";
7
- import { resolve } from "node:path";
8
- import type { AppPlugin, PodokitModule } from "../core/services";
10
+ import { join, resolve } from "node:path";
11
+ import {
12
+ ACCESS_POLICY,
13
+ REQUEST_IDENTITY,
14
+ type AppPlugin,
15
+ type ElysiaPlugin,
16
+ type PodokitModule,
17
+ type RequestIdentity,
18
+ type ServiceKey,
19
+ } from "../core/services";
9
20
  import { PROVIDERS } from "../config/providers";
21
+ import { DownloadUrlSigner, objectKeyFromPath, SignedDownloadStore } from "./download-url";
22
+ import {
23
+ demoKeyParams,
24
+ demoObjectKey,
25
+ requireStorageAdmin,
26
+ storageObjectNotFound,
27
+ } from "./storage-demo";
28
+
29
+ export const DOWNLOAD_BASE_URL = "/files/content";
30
+ export const LOCAL_DOWNLOAD_URLS = Symbol("local-download-urls") as ServiceKey<DownloadUrlSigner>;
10
31
 
11
- function responseBody(object: ObjectData): ReadableStream<Uint8Array> {
12
- const iterator = object.body[Symbol.asyncIterator]();
13
- return new ReadableStream<Uint8Array>({
14
- async pull(controller): Promise<void> {
15
- const next = await iterator.next();
16
- if (next.done) controller.close();
17
- else controller.enqueue(next.value);
18
- },
19
- async cancel(): Promise<void> {
20
- await iterator.return?.();
21
- },
22
- });
32
+ async function readObject(storage: ObjectStore, key: string): Promise<ObjectData> {
33
+ const object = await storage.get(key);
34
+ if (!object) throw storageObjectNotFound();
35
+ return object;
23
36
  }
24
37
 
25
- const localStoragePlugin: AppPlugin = ({ services }) => {
26
- if (PROVIDERS["object-storage"] !== "local") {
27
- return new Elysia({ name: "podokit.storage-local.inactive" });
28
- }
29
- const storage = services.resolve(OBJECT_STORAGE);
38
+ // Local objects are served from the app's own origin with the type the uploader
39
+ // declared. Only render types that cannot run script; everything else downloads,
40
+ // and the sandbox policy blocks script even if a browser renders it anyway.
41
+ const INLINE_CONTENT_TYPES = new Set([
42
+ "image/png",
43
+ "image/jpeg",
44
+ "image/gif",
45
+ "image/webp",
46
+ "image/avif",
47
+ "text/plain",
48
+ ]);
49
+
50
+ export function downloadSafetyHeaders(contentType: string | undefined): Record<string, string> {
51
+ const mediaType = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
52
+ return {
53
+ "x-content-type-options": "nosniff",
54
+ "content-security-policy": "default-src 'none'; sandbox",
55
+ "content-disposition": INLINE_CONTENT_TYPES.has(mediaType) ? "inline" : "attachment",
56
+ };
57
+ }
58
+
59
+ export function localStorageRoutes(
60
+ storage: ObjectStore,
61
+ signer: DownloadUrlSigner,
62
+ identity: RequestIdentity,
63
+ ): ElysiaPlugin {
30
64
  return new Elysia({ name: "podokit.storage-local" })
31
- .get("/files/content/*", async ({ params, set }) => {
32
- const object = await storage.get(params["*"]);
33
- if (object.contentType) set.headers["content-type"] = object.contentType;
34
- set.headers["content-length"] = String(object.size);
35
- return new Response(responseBody(object));
65
+ .get("/files/content/*", async ({ request, params, query }) => {
66
+ const key = objectKeyFromPath(params["*"]);
67
+ if (key === undefined || !(await signer.verify(key, query.expires, query.signature))) {
68
+ throw new AppException(
69
+ "DOWNLOAD_URL_INVALID",
70
+ "The download URL is invalid or has expired",
71
+ 403,
72
+ );
73
+ }
74
+ const object = await readObject(storage, key);
75
+ return objectResponse(request, object, { headers: downloadSafetyHeaders(object.contentType) });
36
76
  }, {
37
- detail: { tags: ["files"], summary: "Read a local object" },
77
+ query: t.Object({
78
+ expires: t.Optional(t.String()),
79
+ signature: t.Optional(t.String()),
80
+ }),
81
+ detail: { tags: ["files"], summary: "Read a local object through a signed download URL" },
38
82
  })
39
- .put("/storage/:key", async ({ params, body }) => {
40
- await storage.put(params.key, body.content, { contentType: "text/plain" });
83
+ .put("/storage/:key", async ({ request, params, body }) => {
84
+ await requireStorageAdmin(identity, request);
85
+ await storage.put(demoObjectKey(params.key), body.content, { contentType: "text/plain" });
41
86
  return { key: params.key };
42
87
  }, {
43
- params: t.Object({ key: t.String({ minLength: 1 }) }),
88
+ params: demoKeyParams,
44
89
  body: t.Object({ content: t.String() }),
45
- detail: { tags: ["storage"], summary: "Store an object" },
90
+ detail: { tags: ["storage"], summary: "Store a demo object (administrators only)" },
91
+ })
92
+ .get("/storage/:key/presigned", async ({ request, params }) => {
93
+ await requireStorageAdmin(identity, request);
94
+ return { url: await storage.getDownloadUrl(demoObjectKey(params.key)) };
95
+ }, {
96
+ params: demoKeyParams,
97
+ detail: { tags: ["storage"], summary: "Sign a demo object download URL (administrators only)" },
98
+ })
99
+ .get("/storage/:key", async ({ request, params }) => {
100
+ await requireStorageAdmin(identity, request);
101
+ const object = await readObject(storage, demoObjectKey(params.key));
102
+ return objectResponse(request, object, { headers: downloadSafetyHeaders(object.contentType) });
103
+ }, {
104
+ params: demoKeyParams,
105
+ detail: { tags: ["storage"], summary: "Read a demo object (administrators only)" },
46
106
  });
107
+ }
108
+
109
+ const localStoragePlugin: AppPlugin = ({ services }) => {
110
+ if (PROVIDERS["object-storage"] !== "local") {
111
+ return new Elysia({ name: "podokit.storage-local.inactive" });
112
+ }
113
+ return localStorageRoutes(
114
+ services.resolve(OBJECT_STORAGE),
115
+ services.resolve(LOCAL_DOWNLOAD_URLS),
116
+ services.resolve(REQUEST_IDENTITY),
117
+ );
47
118
  };
48
119
 
49
120
  export const storageLocalModule: PodokitModule = {
50
121
  name: "object-storage-local",
51
122
  configure: (_env, services): void => {
52
123
  if (PROVIDERS["object-storage"] !== "local") return;
53
- const storage = new LocalObjectStore({
54
- rootDirectory: resolve(process.env.LOCAL_STORAGE_PATH ?? "./data/files"),
55
- publicBaseUrl: "/files/content",
56
- });
124
+ const root = resolve(process.env.LOCAL_STORAGE_PATH ?? "./data/files");
125
+ const signer = new DownloadUrlSigner(join(root, ".podokit", "download-url.key"));
126
+ const storage = new SignedDownloadStore(
127
+ new LocalObjectStore({ rootDirectory: root, publicBaseUrl: DOWNLOAD_BASE_URL }),
128
+ signer,
129
+ DOWNLOAD_BASE_URL,
130
+ );
57
131
  services.register(OBJECT_STORAGE, storage, () => storage.close());
132
+ services.register(LOCAL_DOWNLOAD_URLS, signer);
133
+ // The signature is the authorization, as with an S3 presigned URL.
134
+ services.resolve(ACCESS_POLICY).register("GET", `${DOWNLOAD_BASE_URL}/*`, "public");
58
135
  },
59
136
  plugin: localStoragePlugin,
60
137
  };
@@ -0,0 +1,88 @@
1
+ import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
2
+
3
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
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
+ };
11
+
12
+ type Playwright = PlaywrightWorkerArgs["playwright"];
13
+
14
+ function uniqueKey(): string {
15
+ return `local-${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> {
32
+ const email = `storage-local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
33
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "L" } });
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");
41
+ }
42
+
43
+ test("local storage: signed download URLs reach only the signed object", async ({ playwright }) => {
44
+ const ctx = await newContext(playwright);
45
+ const anonymous = await newContext(playwright);
46
+ const key = uniqueKey();
47
+ try {
48
+ test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
49
+ const presigned = await ctx.get(`/api/storage/${key}/presigned`);
50
+ const { url } = await presigned.json() as { url?: string };
51
+ test.skip(!url?.startsWith("/files/content/"), "object-storage provider is not local");
52
+ if (!url) throw new Error("presign response must include a url");
53
+ expect(url).toContain(`/files/content/demo/${key}?`);
54
+ expect((await ctx.put(`/api/storage/${key}`, { data: { content: "hello local" } })).ok()).toBeTruthy();
55
+
56
+ const download = await anonymous.get(`/api${url}`);
57
+ expect(download.ok()).toBeTruthy();
58
+ expect(await download.text()).toBe("hello local");
59
+
60
+ const unsigned = await anonymous.get(`/api${url.split("?")[0]}`);
61
+ expect(unsigned.status()).toBe(403);
62
+ expect(await unsigned.json()).toMatchObject({ error: { code: "DOWNLOAD_URL_INVALID" } });
63
+
64
+ const altered = await anonymous.get(`/api${url.replace(`demo/${key}`, `demo/${key}-other`)}`);
65
+ expect(altered.status()).toBe(403);
66
+ } finally {
67
+ await ctx.dispose();
68
+ await anonymous.dispose();
69
+ }
70
+ });
71
+
72
+ test("local storage demo routes are limited to administrators", async ({ playwright }) => {
73
+ const user = await newContext(playwright);
74
+ const key = uniqueKey();
75
+ try {
76
+ test.skip(!(await signUp(user)), "requires the auth module");
77
+ for (const response of [
78
+ await user.put(`/api/storage/${key}`, { data: { content: "x" } }),
79
+ await user.get(`/api/storage/${key}`),
80
+ await user.get(`/api/storage/${key}/presigned`),
81
+ ]) {
82
+ expect(response.status()).toBe(403);
83
+ expect(await response.json()).toMatchObject({ error: { code: "ADMIN_REQUIRED" } });
84
+ }
85
+ } finally {
86
+ await user.dispose();
87
+ }
88
+ });
@@ -7,6 +7,9 @@
7
7
  "requiredProjectFiles": [
8
8
  "apps/api/src/config/providers.ts"
9
9
  ],
10
+ "requiredProjectContents": {
11
+ "apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
12
+ },
10
13
  "managedGlobs": [
11
14
  "apps/api/src/storage-local/**"
12
15
  ],
@@ -29,6 +32,8 @@
29
32
  ],
30
33
  "instructions": [
31
34
  "Local object storage is single-host. Back up LOCAL_STORAGE_PATH together with the database.",
32
- "Keep the API at one replica or select the S3 provider."
35
+ "Keep the API at one replica or select the S3 provider.",
36
+ "Download URLs are signed and expire after one hour by default. The signing key lives in LOCAL_STORAGE_PATH/.podokit/download-url.key; deleting it invalidates every issued URL.",
37
+ "The /storage demo routes need the auth module and an administrator session, and only reach keys under demo/."
33
38
  ]
34
39
  }