@podosoft/podokit 0.4.0 → 0.5.1

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 (141) hide show
  1. package/README.md +33 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +55 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -1,59 +1,204 @@
1
1
  import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
2
- import { twoFactor } from "better-auth/plugins";
3
- import { Pool } from "pg";
4
- // podokit:auth-imports
2
+ import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer } from "better-auth/plugins";
3
+ import { actionEmail, sendMail } from "../mail/mailer";
4
+ import { sendSms } from "../sms/sms";
5
+ import { createFeatureGate } from "./feature-gate";
6
+ import { orgAc, orgRoles } from "./org-permissions";
7
+ import { pool } from "./db";
8
+ import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
9
+ import { apiKey } from "@better-auth/api-key";
10
+ import { passkey } from "@better-auth/passkey";
11
+ import { oauthProvider } from "@better-auth/oauth-provider";
12
+ // podokit:begin:auth-imports
13
+ // podokit:end:auth-imports
5
14
 
6
- // OAuth providers are enabled only when their credentials are present.
7
- function socialProviders(): NonNullable<BetterAuthOptions["socialProviders"]> {
8
- const providers: NonNullable<BetterAuthOptions["socialProviders"]> = {};
9
- if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
10
- providers.google = {
11
- clientId: process.env.GOOGLE_CLIENT_ID,
12
- clientSecret: process.env.GOOGLE_CLIENT_SECRET,
13
- };
14
- }
15
- if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
16
- providers.github = {
17
- clientId: process.env.GITHUB_CLIENT_ID,
18
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
19
- };
15
+ // Web origin(s) where the browser runs (WebAuthn ceremonies must match these).
16
+ const webOrigins = (process.env.CORS_ORIGIN ?? "http://localhost:5001").split(",").map((o) => o.trim());
17
+
18
+ // Enabled social providers, from the resolved config (DB-first, env fallback).
19
+ // Admins add/edit/remove providers dynamically (Settings page), so this maps the
20
+ // whole config.social record. Secrets arrive already decrypted; a provider needs
21
+ // enabled + id + secret, and must be a better-auth-supported provider id (the
22
+ // allowlist guards betterAuth() from an unknown key). The provider option shape
23
+ // (clientId/clientSecret/redirectURI) is common to every supported provider.
24
+ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialProviders"]> {
25
+ const providers: Record<string, { clientId: string; clientSecret: string; redirectURI?: string }> = {};
26
+ for (const [id, p] of Object.entries(config.social)) {
27
+ if (!SUPPORTED_PROVIDER_IDS.has(id) || !p.enabled || !p.clientId || !p.clientSecret) continue;
28
+ providers[id] = { clientId: p.clientId, clientSecret: p.clientSecret, ...(p.redirectURI ? { redirectURI: p.redirectURI } : {}) };
20
29
  }
21
- return providers;
30
+ return providers as NonNullable<BetterAuthOptions["socialProviders"]>;
22
31
  }
23
32
 
24
- // Plugins are collected here so other PodoKit modules can add their own.
25
- const plugins: BetterAuthPlugin[] = [];
26
- if (process.env.AUTH_TWO_FACTOR === "true") {
27
- plugins.push(twoFactor());
28
- }
29
- // podokit:auth-plugins
33
+ // Build a better-auth instance from resolved config. Called at boot and rebuilt by
34
+ // auth-provider.ts when the DB config changes, so admin edits (OAuth credentials,
35
+ // server-enforced toggles) apply without a restart. Everything below the injection
36
+ // markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
37
+ export function buildAuth(config: AuthConfig) {
38
+ const plugins: BetterAuthPlugin[] = [
39
+ twoFactor(),
40
+ magicLink({
41
+ sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
42
+ await sendMail({
43
+ to: email,
44
+ subject: "Your sign-in link",
45
+ text: `Sign in: ${url}`,
46
+ html: actionEmail("Sign in to your account", "Click the button below to sign in. This link expires shortly.", url, "Sign in"),
47
+ });
48
+ },
49
+ }),
50
+ emailOTP({
51
+ sendVerificationOTP: async ({ email, otp }: { email: string; otp: string; type: string }) => {
52
+ await sendMail({
53
+ to: email,
54
+ subject: "Your sign-in code",
55
+ text: `Your one-time code is ${otp}. It expires shortly.`,
56
+ html: `<p>Your one-time sign-in code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${otp}</p><p>It expires shortly.</p>`,
57
+ });
58
+ },
59
+ }),
60
+ // Adds username/displayUsername columns; lets users sign in with a username.
61
+ username(),
62
+ // Let one browser hold several signed-in accounts and switch between them.
63
+ multiSession(),
64
+ // Phone-number sign-in/verification. SMS delivery is a dev stub (logs the code);
65
+ // wire a real provider in sendOTP for production.
66
+ phoneNumber({
67
+ sendOTP: async ({ phoneNumber: to, code }: { phoneNumber: string; code: string }) => {
68
+ await sendSms({ to, body: `Your verification code is ${code}` });
69
+ },
70
+ }),
71
+ // User-issued API keys (DB-backed). Distinct from the static X-API-Key module.
72
+ apiKey(),
73
+ // Passwordless sign-in with WebAuthn passkeys. The ceremony runs at the web
74
+ // origin, so expected origins are the CORS origins and rpID is their host.
75
+ // Change rpName to your product name (shown in the browser passkey prompt).
76
+ passkey({ rpName: "PodoKit", rpID: new URL(webOrigins[0]).hostname, origin: webOrigins }),
77
+ // Multi-tenant organizations: teams with members, roles, and email invitations.
78
+ organization({
79
+ // Custom member roles (adds "manager") and a parent-organization link so orgs
80
+ // can form a hierarchy. See org-permissions.ts.
81
+ ac: orgAc,
82
+ roles: orgRoles,
83
+ schema: { organization: { additionalFields: { parentOrganizationId: { type: "string", required: false, input: true } } } },
84
+ sendInvitationEmail: async (data: { email: string; id: string; organization: { name: string }; inviter: { user: { name: string; email: string } } }) => {
85
+ const url = `${webOrigins[0]}/accept-invitation/${data.id}`;
86
+ await sendMail({
87
+ to: data.email,
88
+ subject: `You are invited to ${data.organization.name}`,
89
+ text: `${data.inviter.user.name} invited you to ${data.organization.name}. Accept: ${url}`,
90
+ html: actionEmail(
91
+ `Join ${data.organization.name}`,
92
+ `${data.inviter.user.name} (${data.inviter.user.email}) invited you to join ${data.organization.name}.`,
93
+ url,
94
+ "Accept invitation",
95
+ ),
96
+ });
97
+ },
98
+ }),
99
+ // Accept the session token via `Authorization: Bearer <token>` (returned in the
100
+ // `set-auth-token` response header on sign-in) instead of a cookie — for API and
101
+ // mobile clients. Infrastructure plugin (always on), like jwt(); no UI toggle.
102
+ bearer(),
103
+ // JWT/JWKS signing keys — required by the OAuth provider to sign id_tokens.
104
+ jwt(),
105
+ // Act as an OAuth2/OIDC identity provider: other apps can "Sign in with this app".
106
+ // Register clients at /api/auth/oauth2/create-client; discovery at
107
+ // /api/auth/.well-known/openid-configuration. See docs/modules.md.
108
+ oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
109
+ ];
110
+ // Reject passwords found in known breaches (Have I Been Pwned, k-anonymity range
111
+ // API). Admin-toggleable (auth_config), applied live on the next request.
112
+ if (config.hibp) {
113
+ plugins.push(haveIBeenPwned());
114
+ }
115
+ // podokit:begin:auth-plugins
116
+ // podokit:end:auth-plugins
117
+
118
+ // Request-time hooks. The feature gate turns the admin Settings toggles into a
119
+ // real server boundary (disabled features 404). Other PodoKit modules add their
120
+ // own hooks below the marker.
121
+ const hooks: NonNullable<BetterAuthOptions["hooks"]> = { before: createFeatureGate(pool) };
122
+ // podokit:begin:auth-hooks
123
+ // podokit:end:auth-hooks
30
124
 
31
- export const auth = betterAuth({
32
- database: new Pool({
33
- host: process.env.POSTGRES_HOST ?? "localhost",
34
- port: Number(process.env.POSTGRES_PORT ?? 5432),
35
- user: process.env.POSTGRES_USER ?? "podokit",
36
- password: process.env.POSTGRES_PASSWORD ?? "podokit",
37
- database: process.env.POSTGRES_DB ?? "podokit",
38
- }),
39
- emailAndPassword: {
40
- enabled: true,
41
- // podokit:auth-email-password
42
- },
43
- socialProviders: socialProviders(),
44
- plugins,
45
- secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
46
- baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
47
- advanced: {
48
- ipAddress: {
49
- // The API sits behind the SvelteKit server proxy, which forwards the
50
- // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
51
- ipAddressHeaders: ["x-forwarded-for"],
125
+ return betterAuth({
126
+ database: pool,
127
+ emailAndPassword: {
128
+ enabled: true,
129
+ requireEmailVerification: config.requireEmailVerification,
130
+ sendResetPassword: async ({ user, url }) => {
131
+ await sendMail({
132
+ to: user.email,
133
+ subject: "Reset your password",
134
+ text: `Reset your password: ${url}`,
135
+ html: actionEmail("Reset your password", "Click the button below to choose a new password.", url, "Reset password"),
136
+ });
137
+ },
138
+ // podokit:begin:auth-email-password
139
+ // podokit:end:auth-email-password
52
140
  },
53
- },
54
- user: {
55
- // Self-service account deletion — opt in with AUTH_ALLOW_DELETE=true.
56
- deleteUser: { enabled: process.env.AUTH_ALLOW_DELETE === "true" },
57
- },
58
- // podokit:auth-options
59
- });
141
+ emailVerification: {
142
+ sendOnSignUp: config.requireEmailVerification,
143
+ autoSignInAfterVerification: true,
144
+ sendVerificationEmail: async ({ user, url }) => {
145
+ await sendMail({
146
+ to: user.email,
147
+ subject: "Verify your email",
148
+ text: `Verify your email: ${url}`,
149
+ html: actionEmail("Verify your email", "Confirm your address to finish setting up your account.", url, "Verify email"),
150
+ });
151
+ },
152
+ },
153
+ socialProviders: buildSocial(config),
154
+ plugins,
155
+ hooks,
156
+ secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
157
+ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:5002",
158
+ advanced: {
159
+ ipAddress: {
160
+ // The API sits behind the SvelteKit server proxy, which forwards the
161
+ // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
162
+ ipAddressHeaders: ["x-forwarded-for"],
163
+ },
164
+ },
165
+ user: {
166
+ // Self-service email change. When email verification is on, better-auth sends
167
+ // an approval link to the current address before switching; otherwise it
168
+ // changes immediately.
169
+ changeEmail: {
170
+ enabled: true,
171
+ sendChangeEmailVerification: async ({
172
+ user,
173
+ newEmail,
174
+ url,
175
+ }: {
176
+ user: { email: string };
177
+ newEmail: string;
178
+ url: string;
179
+ }) => {
180
+ await sendMail({
181
+ to: user.email,
182
+ subject: "Approve your email change",
183
+ text: `Approve changing your email to ${newEmail}: ${url}`,
184
+ html: actionEmail(
185
+ "Approve email change",
186
+ `Confirm changing your email address to ${newEmail}.`,
187
+ url,
188
+ "Approve change",
189
+ ),
190
+ });
191
+ },
192
+ },
193
+ // Self-service account deletion — admin-toggleable (auth_config), applied live.
194
+ deleteUser: { enabled: config.allowDelete },
195
+ },
196
+ // podokit:begin:auth-options
197
+ // podokit:end:auth-options
198
+ });
199
+ }
200
+
201
+ // Bootstrap instance from environment config. Used by the better-auth CLI
202
+ // (`--config auth.ts`) and as the guaranteed last-good fallback. The runtime
203
+ // mount (auth-provider.ts) rebuilds from DB config on top of this.
204
+ export const auth = buildAuth(envAuthConfig());
@@ -0,0 +1,12 @@
1
+ import { Pool } from "pg";
2
+
3
+ // One shared pg connection pool, in its own module so it can be imported by
4
+ // auth.ts, the mailer, and the config store without an import cycle (auth.ts
5
+ // imports the mailer, which needs the pool for runtime SMTP config).
6
+ export const pool = new Pool({
7
+ host: process.env.POSTGRES_HOST ?? "localhost",
8
+ port: Number(process.env.POSTGRES_PORT ?? 5432),
9
+ user: process.env.POSTGRES_USER ?? "podokit",
10
+ password: process.env.POSTGRES_PASSWORD ?? "podokit",
11
+ database: process.env.POSTGRES_DB ?? "podokit",
12
+ });
@@ -0,0 +1,56 @@
1
+ import { APIError, createAuthMiddleware } from "better-auth/api";
2
+ import type { Pool } from "pg";
3
+ import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
4
+
5
+ /** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
6
+ * gated when it equals a prefix or starts with a prefix ending in "/". */
7
+ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
8
+ { flag: "twoFactor", prefixes: ["/two-factor/"] },
9
+ { flag: "magicLink", prefixes: ["/sign-in/magic-link", "/magic-link/"] },
10
+ { flag: "emailOtp", prefixes: ["/email-otp/", "/sign-in/email-otp"] },
11
+ { flag: "username", prefixes: ["/sign-in/username"] },
12
+ { flag: "multiSession", prefixes: ["/multi-session/"] },
13
+ { flag: "phoneNumber", prefixes: ["/phone-number/", "/sign-in/phone-number"] },
14
+ { flag: "apiKey", prefixes: ["/api-key/"] },
15
+ { flag: "passkey", prefixes: ["/passkey/", "/sign-in/passkey"] },
16
+ { flag: "organization", prefixes: ["/organization"] },
17
+ { flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
18
+ ];
19
+
20
+ const CACHE_TTL_MS = 3_000;
21
+
22
+ /** Server-side enforcement for the admin Settings toggles: requests to a disabled
23
+ * feature's endpoints are rejected with 404, so "off" is a real boundary rather
24
+ * than hidden UI. Flags are read from app_setting through a small TTL cache
25
+ * (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
26
+ * the shipped defaults apply. */
27
+ export function createFeatureGate(pool: Pool) {
28
+ let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
29
+ let fetchedAt = 0;
30
+
31
+ async function flags(): Promise<Record<FeatureFlag, boolean>> {
32
+ if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
33
+ try {
34
+ const res = await pool.query<{ key: string; value: string }>(
35
+ 'SELECT "key", "value" FROM "app_setting" WHERE "key" = ANY($1)',
36
+ [Object.keys(FLAG_DEFAULTS)],
37
+ );
38
+ const next = { ...FLAG_DEFAULTS };
39
+ for (const row of res.rows) next[row.key as FeatureFlag] = row.value === "true";
40
+ cache = next;
41
+ } catch {
42
+ cache = { ...FLAG_DEFAULTS };
43
+ }
44
+ fetchedAt = Date.now();
45
+ return cache;
46
+ }
47
+
48
+ return createAuthMiddleware(async (ctx) => {
49
+ const feature = FEATURE_PATHS.find((f) =>
50
+ f.prefixes.some((p) => ctx.path === p || ctx.path.startsWith(p)),
51
+ );
52
+ if (!feature) return;
53
+ const enabled = (await flags())[feature.flag];
54
+ if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
55
+ });
56
+ }
@@ -0,0 +1,22 @@
1
+ import { createAccessControl } from "better-auth/plugins/access";
2
+ import { defaultStatements, adminAc, ownerAc, memberAc } from "better-auth/plugins/organization/access";
3
+
4
+ /** Access-control for organization MEMBERS (separate from the app-level roles in
5
+ * permissions.ts). Adds a "manager" role alongside the built-in owner/admin/member
6
+ * — any number of members can hold it, so an org can have several managers. A
7
+ * manager runs the org (members, invitations, settings) but can't delete it. */
8
+ export const orgAc = createAccessControl(defaultStatements);
9
+
10
+ export const orgRoles = {
11
+ owner: ownerAc,
12
+ admin: adminAc,
13
+ manager: orgAc.newRole({
14
+ organization: ["update"],
15
+ member: ["create", "update", "delete"],
16
+ invitation: ["create", "cancel"],
17
+ }),
18
+ member: memberAc,
19
+ };
20
+
21
+ /** Assignable org role names, surfaced to the admin UI. */
22
+ export const ORG_ROLE_NAMES = Object.keys(orgRoles);
@@ -0,0 +1,23 @@
1
+ import { createAccessControl } from "better-auth/plugins/access";
2
+ import { defaultStatements, adminAc, userAc } from "better-auth/plugins/admin/access";
3
+
4
+ /** Access-control statement: the admin plugin's built-in resources (user, session)
5
+ * plus an example "content" resource. Add your own resources and actions here. */
6
+ export const statement = {
7
+ ...defaultStatements,
8
+ content: ["read", "create", "update", "delete"],
9
+ } as const;
10
+
11
+ export const ac = createAccessControl(statement);
12
+
13
+ /** Roles and what each may do. `admin` keeps full user/session control and all
14
+ * content actions; `moderator` manages content only; `user` can read content.
15
+ * Assign roles from the Users admin page; check them with the permission guard. */
16
+ export const roles = {
17
+ admin: ac.newRole({ ...adminAc.statements, content: ["read", "create", "update", "delete"] }),
18
+ moderator: ac.newRole({ content: ["read", "create", "update", "delete"] }),
19
+ user: ac.newRole({ ...userAc.statements, content: ["read"] }),
20
+ };
21
+
22
+ /** Assignable role names, surfaced to the admin UI via /account/capabilities. */
23
+ export const ROLE_NAMES = Object.keys(roles);
@@ -0,0 +1,33 @@
1
+ import { Body, Controller, ForbiddenException, Get, Put } from "@nestjs/common";
2
+ import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
3
+ import { ApiTags } from "@nestjs/swagger";
4
+ import { AuthConfigService, type AuthConfigView, type AuthConfigUpdate } from "./auth-config.service";
5
+
6
+ function isAdmin(session: UserSession): boolean {
7
+ const role = session.user?.role;
8
+ const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
9
+ return roles.includes("admin");
10
+ }
11
+
12
+ // Admin-only management of DB-backed auth config (OAuth providers, SMTP,
13
+ // server-enforced toggles). GET returns non-secret fields + `hasSecret`; PUT
14
+ // encrypts and stores secrets, and the change applies live (the auth instance
15
+ // rebuilds on the next request — see auth/auth-provider.ts). Secrets are never
16
+ // returned to the client.
17
+ @ApiTags("account")
18
+ @Controller("account")
19
+ export class AuthConfigController {
20
+ constructor(private readonly service: AuthConfigService) {}
21
+
22
+ @Get("auth-config")
23
+ async get(@Session() session: UserSession): Promise<AuthConfigView> {
24
+ if (!isAdmin(session)) throw new ForbiddenException("Admins only");
25
+ return this.service.describe();
26
+ }
27
+
28
+ @Put("auth-config")
29
+ async put(@Session() session: UserSession, @Body() body: AuthConfigUpdate): Promise<AuthConfigView> {
30
+ if (!isAdmin(session)) throw new ForbiddenException("Admins only");
31
+ return this.service.update(body);
32
+ }
33
+ }
@@ -0,0 +1,26 @@
1
+ import { Column, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm";
2
+
3
+ /** Admin-managed auth configuration, one row per section: one `social.<id>` row
4
+ * per configured OAuth provider (added/removed dynamically — "social.google",
5
+ * "social.github", "social.apple", …), plus "smtp" and "server". Non-secret
6
+ * fields live in `config` (jsonb); secrets (OAuth client secret, SMTP password)
7
+ * are stored encrypted in `secret` (AES-256-GCM envelope — see auth/secret.ts).
8
+ * The runtime reads this table through auth/config-store.ts (raw pool) to
9
+ * (re)build better-auth. */
10
+ @Entity({ name: "auth_config" })
11
+ export class AuthConfigRow {
12
+ @PrimaryColumn({ type: "text" })
13
+ key!: string;
14
+
15
+ @Column({ type: "boolean", default: false })
16
+ enabled!: boolean;
17
+
18
+ @Column({ type: "jsonb", default: {} })
19
+ config!: Record<string, unknown>;
20
+
21
+ @Column({ type: "text", nullable: true })
22
+ secret!: string | null;
23
+
24
+ @UpdateDateColumn()
25
+ updatedAt!: Date;
26
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { TypeOrmModule } from "@nestjs/typeorm";
3
+ import { AuthConfigRow } from "./auth-config.entity";
4
+ import { AuthConfigService } from "./auth-config.service";
5
+ import { AuthConfigController } from "./auth-config.controller";
6
+
7
+ @Module({
8
+ imports: [TypeOrmModule.forFeature([AuthConfigRow])],
9
+ controllers: [AuthConfigController],
10
+ providers: [AuthConfigService],
11
+ })
12
+ export class AuthConfigModule {}
@@ -0,0 +1,118 @@
1
+ import { BadRequestException, Injectable } from "@nestjs/common";
2
+ import { InjectRepository } from "@nestjs/typeorm";
3
+ import { Repository } from "typeorm";
4
+ import { AuthConfigRow } from "./auth-config.entity";
5
+ import { encryptSecret, envAuthConfig, socialKey, SUPPORTED_PROVIDER_IDS, SUPPORTED_SOCIAL_PROVIDERS } from "@podosoft/podokit-auth";
6
+
7
+ /** One configured social provider as shown to the admin (no secret — just a
8
+ * `hasSecret` flag). */
9
+ export type SocialProviderView = { id: string; enabled: boolean; clientId: string; redirectURI: string; hasSecret: boolean };
10
+
11
+ /** Admin-facing view — non-secret fields plus a `hasSecret` flag. Never exposes
12
+ * the stored client secret / SMTP password. Social providers are dynamic: the
13
+ * map holds one entry per configured provider (google, github, apple, …). */
14
+ export type AuthConfigView = {
15
+ social: Record<string, SocialProviderView>;
16
+ /** Catalog of addable providers (id + display label) for the "add provider" picker. */
17
+ catalog: ReadonlyArray<{ id: string; label: string }>;
18
+ smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
19
+ server: { requireEmailVerification: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
20
+ };
21
+
22
+ type ProviderUpdate = { enabled?: boolean; clientId?: string; clientSecret?: string; redirectURI?: string; delete?: boolean };
23
+ export type AuthConfigUpdate = {
24
+ /** Per-provider upsert (keyed by provider id); `{ delete: true }` removes it. */
25
+ social?: Record<string, ProviderUpdate>;
26
+ smtp?: { enabled?: boolean; host?: string; port?: number; secure?: boolean; user?: string; pass?: string; from?: string };
27
+ server?: { requireEmailVerification?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
28
+ };
29
+
30
+ @Injectable()
31
+ export class AuthConfigService {
32
+ constructor(@InjectRepository(AuthConfigRow) private readonly repo: Repository<AuthConfigRow>) {}
33
+
34
+ async describe(): Promise<AuthConfigView> {
35
+ const rows = await this.repo.find();
36
+ const byKey = new Map(rows.map((r) => [r.key, r]));
37
+ const env = envAuthConfig();
38
+
39
+ const social: Record<string, SocialProviderView> = {};
40
+ for (const row of rows) {
41
+ if (!row.key.startsWith("social.")) continue;
42
+ const id = row.key.slice("social.".length);
43
+ if (!SUPPORTED_PROVIDER_IDS.has(id)) continue;
44
+ const c = (row.config ?? {}) as { clientId?: string; redirectURI?: string };
45
+ social[id] = { id, enabled: row.enabled, clientId: c.clientId ?? "", redirectURI: c.redirectURI ?? "", hasSecret: !!row.secret };
46
+ }
47
+ // Surface env-configured providers (google/github) that have no DB row yet, so
48
+ // the admin sees them as already-active rather than missing.
49
+ for (const [id, p] of Object.entries(env.social)) {
50
+ if (!social[id]) social[id] = { id, enabled: p.enabled, clientId: p.clientId, redirectURI: p.redirectURI ?? "", hasSecret: !!p.clientSecret };
51
+ }
52
+
53
+ const smtpRow = byKey.get("smtp");
54
+ const smtpC = (smtpRow?.config ?? {}) as { host?: string; port?: number; secure?: boolean; user?: string; from?: string };
55
+ const serverRow = byKey.get("server");
56
+ const serverC = (serverRow?.config ?? {}) as { requireEmailVerification?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
57
+ return {
58
+ social,
59
+ catalog: SUPPORTED_SOCIAL_PROVIDERS,
60
+ smtp: {
61
+ enabled: smtpRow?.enabled ?? false,
62
+ host: smtpC.host ?? "",
63
+ port: smtpC.port ?? 587,
64
+ secure: smtpC.secure ?? false,
65
+ user: smtpC.user ?? "",
66
+ from: smtpC.from ?? "",
67
+ hasSecret: !!smtpRow?.secret,
68
+ },
69
+ server: {
70
+ requireEmailVerification: serverC.requireEmailVerification ?? env.requireEmailVerification,
71
+ allowDelete: serverC.allowDelete ?? env.allowDelete,
72
+ hibp: serverC.hibp ?? env.hibp,
73
+ auditLog: serverC.auditLog ?? env.auditLog,
74
+ },
75
+ };
76
+ }
77
+
78
+ async update(dto: AuthConfigUpdate): Promise<AuthConfigView> {
79
+ for (const [id, u] of Object.entries(dto.social ?? {})) {
80
+ if (!SUPPORTED_PROVIDER_IDS.has(id)) throw new BadRequestException(`Unsupported social provider: ${id}`);
81
+ if (u.delete) await this.repo.delete({ key: socialKey(id) });
82
+ else await this.upsertProvider(socialKey(id), u);
83
+ }
84
+ if (dto.smtp) await this.upsertSmtp(dto.smtp);
85
+ if (dto.server) await this.upsertServer(dto.server);
86
+ return this.describe();
87
+ }
88
+
89
+ private async upsertProvider(key: string, u: ProviderUpdate): Promise<void> {
90
+ const row = (await this.repo.findOneBy({ key })) ?? this.repo.create({ key, enabled: false, config: {}, secret: null });
91
+ const config = { ...(row.config as { clientId?: string; redirectURI?: string }) };
92
+ if (u.clientId !== undefined) config.clientId = u.clientId;
93
+ if (u.redirectURI !== undefined) config.redirectURI = u.redirectURI;
94
+ if (u.enabled !== undefined) row.enabled = u.enabled;
95
+ // Replace the secret only when a non-empty value is supplied; otherwise keep it.
96
+ if (u.clientSecret) row.secret = encryptSecret(u.clientSecret);
97
+ row.config = config;
98
+ await this.repo.save(row);
99
+ }
100
+
101
+ private async upsertSmtp(u: NonNullable<AuthConfigUpdate["smtp"]>): Promise<void> {
102
+ const row = (await this.repo.findOneBy({ key: "smtp" })) ?? this.repo.create({ key: "smtp", enabled: false, config: {}, secret: null });
103
+ const config = { ...(row.config as Record<string, unknown>) };
104
+ for (const f of ["host", "port", "secure", "user", "from"] as const) if (u[f] !== undefined) config[f] = u[f];
105
+ if (u.enabled !== undefined) row.enabled = u.enabled;
106
+ if (u.pass) row.secret = encryptSecret(u.pass);
107
+ row.config = config;
108
+ await this.repo.save(row);
109
+ }
110
+
111
+ private async upsertServer(u: NonNullable<AuthConfigUpdate["server"]>): Promise<void> {
112
+ const row = (await this.repo.findOneBy({ key: "server" })) ?? this.repo.create({ key: "server", enabled: true, config: {}, secret: null });
113
+ const config = { ...(row.config as Record<string, unknown>) };
114
+ for (const f of ["requireEmailVerification", "allowDelete", "hibp", "auditLog"] as const) if (u[f] !== undefined) config[f] = u[f];
115
+ row.config = config;
116
+ await this.repo.save(row);
117
+ }
118
+ }
@@ -0,0 +1,65 @@
1
+ import nodemailer, { type Transporter } from "nodemailer";
2
+ import { pool } from "../auth/db";
3
+ import { createConfigStore, type SmtpConfig } from "@podosoft/podokit-auth";
4
+
5
+ // SMTP is admin-configurable in the DB (auth_config), falling back to SMTP_* env,
6
+ // and applied live: the transport is rebuilt when the config changes (short TTL),
7
+ // so no restart is needed. With neither DB nor env config, fall back to a transport
8
+ // that just logs the message so nothing crashes for lack of mail config.
9
+ const store = createConfigStore(pool);
10
+ const TTL_MS = 3_000;
11
+
12
+ function buildTransport(smtp: SmtpConfig | null): Transporter {
13
+ if (!smtp) return nodemailer.createTransport({ jsonTransport: true });
14
+ return nodemailer.createTransport({
15
+ host: smtp.host,
16
+ port: smtp.port,
17
+ secure: smtp.secure,
18
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass ?? "" } : undefined,
19
+ });
20
+ }
21
+
22
+ let transport = buildTransport(null);
23
+ let transportFrom = process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
24
+ let hasSmtp = false;
25
+ let checkedAt = 0;
26
+ let signature = "";
27
+
28
+ async function getTransport(): Promise<Transporter> {
29
+ if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return transport;
30
+ checkedAt = Date.now();
31
+ try {
32
+ const smtp = await store.smtpConfig();
33
+ const next = JSON.stringify(smtp ?? null);
34
+ if (next !== signature) {
35
+ transport = buildTransport(smtp);
36
+ transportFrom = smtp?.from ?? process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
37
+ hasSmtp = !!smtp;
38
+ signature = next;
39
+ }
40
+ } catch {
41
+ /* keep the last-good transport */
42
+ }
43
+ return transport;
44
+ }
45
+
46
+ export type Mail = { to: string; subject: string; text: string; html?: string };
47
+
48
+ export async function sendMail(mail: Mail): Promise<void> {
49
+ const t = await getTransport();
50
+ const info = await t.sendMail({ from: transportFrom, ...mail });
51
+ if (!hasSmtp) {
52
+ // JSON transport: surface the message so the link is grabbable from logs.
53
+ console.log(`[mailer] no SMTP configured; message not delivered:\n${info.message as string}`);
54
+ }
55
+ }
56
+
57
+ // Minimal HTML wrapper for a titled call-to-action email.
58
+ export function actionEmail(title: string, body: string, url: string, cta: string): string {
59
+ return `<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto">
60
+ <h1 style="font-size:20px">${title}</h1>
61
+ <p>${body}</p>
62
+ <p><a href="${url}" style="display:inline-block;padding:10px 16px;background:#111;color:#fff;border-radius:8px;text-decoration:none">${cta}</a></p>
63
+ <p style="color:#666;font-size:13px">Or paste this link into your browser:<br>${url}</p>
64
+ </div>`;
65
+ }