@podosoft/podokit 1.1.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { SmtpConfig } from "@podosoft/podokit-auth";
3
+ import type { Transporter } from "nodemailer";
4
+ import { mailTransport, setMailConfigStore, setMailTransport } from "./mailer";
5
+
6
+ function storeOf(read: () => SmtpConfig | null) {
7
+ let calls = 0;
8
+ return {
9
+ store: {
10
+ smtpConfig: async (): Promise<SmtpConfig | null> => {
11
+ calls += 1;
12
+ return read();
13
+ },
14
+ },
15
+ calls: (): number => calls,
16
+ };
17
+ }
18
+
19
+ afterEach(() => {
20
+ setMailTransport(null);
21
+ });
22
+
23
+ describe("mailer transport", () => {
24
+ test("reuses the transport while the SMTP settings are unchanged", async () => {
25
+ let smtp: SmtpConfig | null = { host: "smtp.example.com", port: 587, secure: false, user: "u", pass: "p" };
26
+ const { store, calls } = storeOf(() => smtp);
27
+ setMailConfigStore(store);
28
+
29
+ const first = await mailTransport();
30
+ expect(await mailTransport()).toBe(first);
31
+ smtp = { ...smtp };
32
+ expect(await mailTransport()).toBe(first);
33
+ expect(calls()).toBe(3);
34
+
35
+ smtp = { ...smtp, host: "smtp2.example.com" };
36
+ const second = await mailTransport();
37
+ expect(second).not.toBe(first);
38
+ expect(await mailTransport()).toBe(second);
39
+ });
40
+
41
+ test("falls back to the environment when the store fails, and honours an override", async () => {
42
+ setMailConfigStore({
43
+ smtpConfig: async () => {
44
+ throw new Error('relation "auth_config" does not exist');
45
+ },
46
+ });
47
+ const fallback = await mailTransport();
48
+ expect(fallback).toBeDefined();
49
+
50
+ const custom = { sendMail: async () => ({}) } as unknown as Transporter;
51
+ setMailTransport(custom);
52
+ expect(await mailTransport()).toBe(custom);
53
+ });
54
+ });
@@ -1,17 +1,18 @@
1
1
  import nodemailer, { type Transporter } from "nodemailer";
2
2
  import { createConfigStore, type SmtpConfig } from "@podosoft/podokit-auth";
3
3
 
4
- // Reusable email sending. SMTP is resolved in priority order, applied live (short
5
- // TTL) so no restart is needed:
4
+ // Reusable email sending. SMTP is resolved in priority order, applied live so no
5
+ // restart is needed:
6
6
  // 1. admin-configured DB settings (auth_config) — only when the auth module is
7
- // installed; read via the config store, which decrypts the SMTP password;
7
+ // installed; read from the shared config snapshot, which decrypts the SMTP
8
+ // password once per change and follows the snapshot TTL;
8
9
  // 2. SMTP_* env (fallback / production secrets-manager injection);
9
10
  // 3. none → a JSON transport that logs the message so dev links are grabbable.
10
11
  // The whole transport can also be overridden from app code (setMailTransport),
11
12
  // e.g. from apps/api/src/app.extensions.ts, to plug in a provider SDK.
12
13
  type MailConfigStore = Pick<ReturnType<typeof createConfigStore>, "smtpConfig">;
13
14
  let store: MailConfigStore | undefined;
14
- const TTL_MS = 3_000;
15
+ const DEFAULT_FROM = "PodoKit <no-reply@example.com>";
15
16
 
16
17
  export function setMailConfigStore(configStore: MailConfigStore): void {
17
18
  store = configStore;
@@ -49,11 +50,19 @@ function buildTransport(smtp: SmtpConfig | null): Transporter {
49
50
  });
50
51
  }
51
52
 
52
- let transport = buildTransport(null);
53
- let transportFrom = process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
54
- let hasSmtp = false;
55
- let checkedAt = 0;
56
- let signature = "";
53
+ type MailSetup = {
54
+ smtp: SmtpConfig | null;
55
+ signature: string;
56
+ transport: Transporter;
57
+ from: string;
58
+ };
59
+
60
+ let setup: MailSetup = {
61
+ smtp: null,
62
+ signature: "null",
63
+ transport: buildTransport(null),
64
+ from: process.env.MAIL_FROM ?? DEFAULT_FROM,
65
+ };
57
66
  let override: Transporter | null = null;
58
67
 
59
68
  /** Override the transport from app code (e.g. a provider SDK). Pass null to
@@ -62,31 +71,44 @@ export function setMailTransport(custom: Transporter | null): void {
62
71
  override = custom;
63
72
  }
64
73
 
65
- async function getTransport(): Promise<Transporter> {
66
- if (override) return override;
67
- if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return transport;
68
- checkedAt = Date.now();
74
+ /** The config-driven setup. The store returns the same SMTP object until the
75
+ * config changes, and the transport is rebuilt only when its settings differ. */
76
+ async function currentSetup(): Promise<MailSetup> {
77
+ const smtp = await resolveSmtp();
78
+ if (smtp === setup.smtp) return setup;
79
+ const signature = JSON.stringify(smtp);
80
+ if (signature === setup.signature) {
81
+ setup = { ...setup, smtp };
82
+ return setup;
83
+ }
69
84
  try {
70
- const smtp = await resolveSmtp();
71
- const next = JSON.stringify(smtp ?? null);
72
- if (next !== signature) {
73
- transport = buildTransport(smtp);
74
- transportFrom = smtp?.from ?? process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
75
- hasSmtp = !!smtp;
76
- signature = next;
77
- }
85
+ setup = {
86
+ smtp,
87
+ signature,
88
+ transport: buildTransport(smtp),
89
+ from: smtp?.from ?? process.env.MAIL_FROM ?? DEFAULT_FROM,
90
+ };
78
91
  } catch {
79
92
  /* keep the last-good transport */
80
93
  }
81
- return transport;
94
+ return setup;
95
+ }
96
+
97
+ /** The transport `sendMail` uses now: the override, or the one built from SMTP config. */
98
+ export async function mailTransport(): Promise<Transporter> {
99
+ return override ?? (await currentSetup()).transport;
82
100
  }
83
101
 
84
102
  export type Mail = { to: string; subject: string; text: string; html?: string };
85
103
 
86
104
  export async function sendMail(mail: Mail): Promise<void> {
87
- const t = await getTransport();
88
- const info = await t.sendMail({ from: transportFrom, ...mail });
89
- if (!override && !hasSmtp) {
105
+ if (override) {
106
+ await override.sendMail({ from: setup.from, ...mail });
107
+ return;
108
+ }
109
+ const current = await currentSetup();
110
+ const info = await current.transport.sendMail({ from: current.from, ...mail });
111
+ if (!current.smtp) {
90
112
  // JSON transport: surface the message so the link is grabbable from logs.
91
113
  console.log(`[mailer] no SMTP configured; message not delivered:\n${info.message as string}`);
92
114
  }
@@ -5,7 +5,7 @@
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "nodemailer": "^9.0.5",
8
- "@podosoft/podokit-auth": "^0.4.0"
8
+ "@podosoft/podokit-auth": "^0.5.0"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/nodemailer": "^8.0.1"
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { LocalObjectStore } from "@podosoft/podokit-runtime";
6
+ import { DownloadUrlSigner, objectKeyFromPath, SignedDownloadStore } from "./download-url";
7
+
8
+ let root: string | undefined;
9
+
10
+ async function tempRoot(): Promise<string> {
11
+ root = await mkdtemp(join(tmpdir(), "podokit-download-url-"));
12
+ return root;
13
+ }
14
+
15
+ afterEach(async () => {
16
+ if (root) await rm(root, { recursive: true, force: true });
17
+ root = undefined;
18
+ });
19
+
20
+ describe("DownloadUrlSigner", () => {
21
+ it("accepts only the signed key before it expires", async () => {
22
+ const keyFile = join(await tempRoot(), ".podokit", "download-url.key");
23
+ const now = 1_700_000_000_000;
24
+ const signer = new DownloadUrlSigner(keyFile, () => now);
25
+ const { expires, signature } = await signer.sign("uploads/a/file.txt", 60);
26
+
27
+ expect(expires).toBe(String(now / 1000 + 60));
28
+ expect(await signer.verify("uploads/a/file.txt", expires, signature)).toBe(true);
29
+ expect(await signer.verify("uploads/b/file.txt", expires, signature)).toBe(false);
30
+ expect(await signer.verify("uploads/a/file.txt", String(Number(expires) + 1), signature)).toBe(false);
31
+ expect(await signer.verify("uploads/a/file.txt", expires, `${signature.slice(0, -2)}AA`)).toBe(false);
32
+ expect(await signer.verify("uploads/a/file.txt", undefined, signature)).toBe(false);
33
+ expect(await signer.verify("uploads/a/file.txt", expires, undefined)).toBe(false);
34
+ expect(await signer.verify("uploads/a/file.txt", "1e9", signature)).toBe(false);
35
+
36
+ const later = new DownloadUrlSigner(keyFile, () => now + 61_000);
37
+ expect(await later.verify("uploads/a/file.txt", expires, signature)).toBe(false);
38
+ });
39
+
40
+ it("persists one private key for every signer that shares the key file", async () => {
41
+ const keyFile = join(await tempRoot(), ".podokit", "download-url.key");
42
+ const [first, second] = [new DownloadUrlSigner(keyFile), new DownloadUrlSigner(keyFile)];
43
+ const [signed] = await Promise.all([first.sign("demo/hello"), second.sign("demo/other")]);
44
+
45
+ expect(await second.verify("demo/hello", signed.expires, signed.signature)).toBe(true);
46
+ expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
47
+ });
48
+
49
+ it("caps the lifetime at seven days", async () => {
50
+ const keyFile = join(await tempRoot(), "download-url.key");
51
+ const signer = new DownloadUrlSigner(keyFile, () => 0);
52
+ const { expires } = await signer.sign("demo/hello", 365 * 24 * 60 * 60);
53
+ expect(Number(expires)).toBe(7 * 24 * 60 * 60);
54
+ });
55
+ });
56
+
57
+ describe("objectKeyFromPath", () => {
58
+ it("decodes each path segment and rejects malformed encodings", () => {
59
+ expect(objectKeyFromPath("uploads/user%20one/a.txt")).toBe("uploads/user one/a.txt");
60
+ expect(objectKeyFromPath("uploads/%E0%A4%A")).toBeUndefined();
61
+ });
62
+ });
63
+
64
+ describe("SignedDownloadStore", () => {
65
+ it("adds a verifiable signature to local download URLs", async () => {
66
+ const directory = await tempRoot();
67
+ const signer = new DownloadUrlSigner(join(directory, ".podokit", "download-url.key"));
68
+ const store = new SignedDownloadStore(
69
+ new LocalObjectStore({ rootDirectory: directory, publicBaseUrl: "/files/content" }),
70
+ signer,
71
+ "/files/content",
72
+ );
73
+ await store.put("uploads/user one.txt", "hello", { contentType: "text/plain" });
74
+
75
+ const url = new URL(await store.getDownloadUrl("uploads/user one.txt"), "http://localhost");
76
+ expect(url.pathname).toBe("/files/content/uploads/user%20one.txt");
77
+ expect(
78
+ await signer.verify(
79
+ "uploads/user one.txt",
80
+ url.searchParams.get("expires") ?? undefined,
81
+ url.searchParams.get("signature") ?? undefined,
82
+ ),
83
+ ).toBe(true);
84
+ await expect(store.getDownloadUrl("../outside.txt")).rejects.toThrow("safe relative path");
85
+ });
86
+ });
@@ -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
  });