@podosoft/podokit 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -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
  }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  clientAddressFromProxy,
4
4
  rateLimitConfig,
5
+ sessionSigningSecret,
5
6
  } from "./rate-limit.config";
6
7
 
7
8
  describe("rate limit configuration", () => {
@@ -88,4 +89,12 @@ describe("rate limit configuration", () => {
88
89
  }),
89
90
  ).toBe("10.0.0.9");
90
91
  });
92
+
93
+ test("reads the session signing secret the way the auth module does", () => {
94
+ expect(
95
+ sessionSigningSecret({ BETTER_AUTH_SECRET: " session-secret-with-at-least-32-chars " }),
96
+ ).toBe("session-secret-with-at-least-32-chars");
97
+ expect(sessionSigningSecret({ BETTER_AUTH_SECRET: " " })).toBeUndefined();
98
+ expect(sessionSigningSecret({})).toBeUndefined();
99
+ });
91
100
  });
@@ -84,6 +84,13 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
84
84
  };
85
85
  }
86
86
 
87
+ // The secret Better Auth signs session cookies with (the auth module trims it the
88
+ // same way). Without it, sessions are not recognized and requests are counted by
89
+ // client address.
90
+ export function sessionSigningSecret(env: NodeJS.ProcessEnv = process.env): string | undefined {
91
+ return env.BETTER_AUTH_SECRET?.trim() || undefined;
92
+ }
93
+
87
94
  function normalizeIp(value: string | undefined): string | undefined {
88
95
  if (!value) return undefined;
89
96
  const trimmed = value.trim();
@@ -1,4 +1,5 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, mock, test, type Mock } from "bun:test";
2
3
  import { ApiKeyVerifier } from "../api-key/api-key-verifier";
3
4
  import {
4
5
  REQUEST_IDENTITY,
@@ -8,36 +9,130 @@ import {
8
9
  RATE_LIMIT_IDENTITY_EXTENSION,
9
10
  RateLimitIdentity,
10
11
  RateLimitIdentityExtension,
12
+ verifiedSessionToken,
11
13
  } from "./rate-limit.identity";
12
14
 
13
15
  const proxyConfig = { proxyHeader: "x-forwarded-for", trustedProxyHops: 1 };
16
+ const secret = "rate-limit-test-secret-with-at-least-32-characters";
17
+ const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
14
18
 
15
19
  function request(headers: Record<string, string> = {}): Request {
16
20
  return new Request("http://localhost/ordinary", { headers });
17
21
  }
18
22
 
19
- function services(userId?: string): ServiceRegistry {
23
+ // The same format Better Auth issues: `${token}.${base64(HMAC-SHA256)}`.
24
+ function signed(token: string, key = secret): string {
25
+ return `${token}.${createHmac("sha256", key).update(token).digest("base64")}`;
26
+ }
27
+
28
+ function tamperedSignature(value: string): string {
29
+ const index = value.lastIndexOf(".") + 1;
30
+ const replacement = value[index] === "A" ? "B" : "A";
31
+ return `${value.slice(0, index)}${replacement}${value.slice(index + 1)}`;
32
+ }
33
+
34
+ function sessionCookie(value: string, name = "better-auth.session_token"): string {
35
+ return `theme=dark; ${name}=${encodeURIComponent(value)}`;
36
+ }
37
+
38
+ function rejectingVerifier(): ApiKeyVerifier {
39
+ return { isValid: mock(() => false) } as unknown as ApiKeyVerifier;
40
+ }
41
+
42
+ // Registers a request identity that would hit the session store, so tests can
43
+ // prove the limiter never asks for it.
44
+ function servicesWithSessionStore(): {
45
+ registry: ServiceRegistry;
46
+ userId: Mock<(request: Request) => Promise<string | undefined>>;
47
+ } {
20
48
  const registry = new ServiceRegistry();
21
- registry.register(REQUEST_IDENTITY, { userId: async () => userId });
22
- return registry;
49
+ const userId = mock(async (_request: Request): Promise<string | undefined> => "user-secret-id");
50
+ registry.register(REQUEST_IDENTITY, {
51
+ userId,
52
+ isAdmin: async () => false,
53
+ roles: async () => [],
54
+ activeOrganizationId: async () => undefined,
55
+ });
56
+ return { registry, userId };
23
57
  }
24
58
 
25
59
  describe("RateLimitIdentity", () => {
26
- test("uses an authenticated user without exposing the user id", async () => {
27
- const verifier = { isValid: mock(() => true) } as unknown as ApiKeyVerifier;
28
- const identity = new RateLimitIdentity(verifier, services("user-secret-id"));
60
+ test("uses a verified session cookie without resolving the session", async () => {
61
+ const { registry, userId } = servicesWithSessionStore();
62
+ const identity = new RateLimitIdentity(rejectingVerifier(), registry, secret);
63
+
29
64
  const value = await identity.resolve(
30
- request({ "x-api-key": "api-secret" }),
65
+ request({ cookie: sessionCookie(signed(sessionToken)) }),
66
+ "10.0.0.9",
67
+ proxyConfig,
68
+ );
69
+
70
+ expect(value).toMatch(/^session:[a-f0-9]{64}$/);
71
+ expect(value).not.toContain(sessionToken);
72
+ expect(userId).not.toHaveBeenCalled();
73
+ });
74
+
75
+ test("gives one session the same counter across cookie names and bearer forms", async () => {
76
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
77
+ const value = signed(sessionToken);
78
+ const resolved = await Promise.all([
79
+ identity.resolve(request({ cookie: sessionCookie(value) }), "10.0.0.9", proxyConfig),
80
+ identity.resolve(
81
+ request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
82
+ "10.0.0.9",
83
+ proxyConfig,
84
+ ),
85
+ identity.resolve(request({ authorization: `Bearer ${value}` }), "10.0.0.9", proxyConfig),
86
+ identity.resolve(
87
+ request({ authorization: `bearer ${encodeURIComponent(value)}` }),
88
+ "10.0.0.9",
89
+ proxyConfig,
90
+ ),
91
+ ]);
92
+ expect(new Set(resolved).size).toBe(1);
93
+
94
+ const other = await identity.resolve(
95
+ request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
31
96
  "10.0.0.9",
32
97
  proxyConfig,
33
98
  );
34
- expect(value).toMatch(/^user:[a-f0-9]{64}$/);
35
- expect(value).not.toContain("user-secret-id");
36
- expect(verifier.isValid).not.toHaveBeenCalled();
99
+ expect(other).toMatch(/^session:/);
100
+ expect(other).not.toBe(resolved[0]);
101
+ });
102
+
103
+ test("counts forged, tampered, and unsigned session values by client address", async () => {
104
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
105
+ const address = { "x-forwarded-for": "203.0.113.7" };
106
+ const anonymous = await identity.resolve(request(address), "10.0.0.9", proxyConfig);
107
+ expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
108
+
109
+ const genuine = signed(sessionToken);
110
+ const forgedValues = [
111
+ signed(sessionToken, "an-attacker-chosen-secret-of-32-characters"),
112
+ `${sessionToken}x${genuine.slice(sessionToken.length)}`,
113
+ tamperedSignature(genuine),
114
+ genuine.replace("=", ""),
115
+ sessionToken,
116
+ "",
117
+ ];
118
+ for (const forged of forgedValues) {
119
+ const variants: Array<Record<string, string>> = [
120
+ { cookie: sessionCookie(forged) },
121
+ { authorization: `Bearer ${forged}` },
122
+ ];
123
+ for (const headers of variants) {
124
+ const value = await identity.resolve(
125
+ request({ ...address, ...headers }),
126
+ "10.0.0.9",
127
+ proxyConfig,
128
+ );
129
+ expect(value).toBe(anonymous);
130
+ }
131
+ }
37
132
  });
38
133
 
39
- test("uses configured and extension API keys without storing raw keys", async () => {
40
- const registry = services();
134
+ test("checks API keys before sessions without storing raw keys", async () => {
135
+ const registry = new ServiceRegistry();
41
136
  class Extension extends RateLimitIdentityExtension {
42
137
  async validatedApiKeyId(_request: Request, rawApiKey: string): Promise<string | undefined> {
43
138
  return rawApiKey === "external-valid" ? "external-stable-id" : undefined;
@@ -47,26 +142,49 @@ describe("RateLimitIdentity", () => {
47
142
  const verifier = {
48
143
  isValid: mock((value: string) => value === "configured-valid"),
49
144
  } as unknown as ApiKeyVerifier;
50
- const identity = new RateLimitIdentity(verifier, registry);
145
+ const identity = new RateLimitIdentity(verifier, registry, secret);
146
+ const cookie = sessionCookie(signed(sessionToken));
51
147
 
52
148
  for (const raw of ["configured-valid", "external-valid"]) {
53
- const value = await identity.resolve(request({ "x-api-key": raw }), "10.0.0.9", proxyConfig);
149
+ const value = await identity.resolve(
150
+ request({ "x-api-key": raw, cookie }),
151
+ "10.0.0.9",
152
+ proxyConfig,
153
+ );
54
154
  expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
55
155
  expect(value).not.toContain(raw);
56
156
  }
57
- });
58
157
 
59
- test("falls back to the trusted client address", async () => {
60
- const identity = new RateLimitIdentity(
61
- { isValid: mock(() => false) } as unknown as ApiKeyVerifier,
62
- services(),
158
+ const invalidKey = await identity.resolve(
159
+ request({ "x-api-key": "invalid", cookie }),
160
+ "10.0.0.9",
161
+ proxyConfig,
63
162
  );
163
+ expect(invalidKey).toMatch(/^session:/);
164
+ });
165
+
166
+ test("falls back to the trusted client address without authentication", async () => {
167
+ const cookie = sessionCookie(signed(sessionToken));
168
+ // No session secret: the auth module is not installed or not configured.
169
+ const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
64
170
  const value = await identity.resolve(
65
- request({ "x-forwarded-for": "203.0.113.7" }),
171
+ request({ "x-forwarded-for": "203.0.113.7", cookie }),
66
172
  "10.0.0.9",
67
173
  proxyConfig,
68
174
  );
69
175
  expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
70
176
  expect(value).not.toContain("203.0.113.7");
177
+ expect(
178
+ await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), "10.0.0.9", proxyConfig),
179
+ ).toBe(value);
180
+ });
181
+ });
182
+
183
+ describe("verifiedSessionToken", () => {
184
+ test("returns the token only for a matching signature", () => {
185
+ expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
186
+ expect(verifiedSessionToken(signed(sessionToken), `${secret}-rotated`)).toBeUndefined();
187
+ expect(verifiedSessionToken(".", secret)).toBeUndefined();
188
+ expect(verifiedSessionToken(`${sessionToken}.`, secret)).toBeUndefined();
71
189
  });
72
190
  });