@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,17 +1,49 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ test("admin can create a user with a custom role @smoke", async ({ page }) => {
5
+ await ready(page, "/admin/users");
6
+ await page.getByRole("button", { name: "Add user" }).click();
7
+ const role = page.locator("#c-role");
8
+ test.skip((await role.count()) === 0, "role picker not present");
9
+ const email = `mod-${Date.now()}@example.com`;
10
+ await page.getByLabel("Name", { exact: true }).fill("Mod");
11
+ await page.getByLabel("Email", { exact: true }).fill(email);
12
+ await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
13
+ await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
14
+ // pick the Moderator role from the select
15
+ await role.click();
16
+ await page.getByRole("option", { name: "Moderator" }).click();
17
+ await page.getByRole("button", { name: "Create", exact: true }).click();
18
+ await expect(page.getByText("User created")).toBeVisible();
19
+ // it appears in the list, filterable by the Moderator role
20
+ await page.locator("#toolbar-search").fill(email);
21
+ await page.getByRole("button", { name: "Search", exact: true }).click();
22
+ await expect(page.getByRole("cell", { name: email })).toBeVisible();
23
+ });
2
24
 
3
25
  test("admin sees the user list and can search @smoke", async ({ page }) => {
4
- await page.goto("/admin/users");
26
+ await ready(page, "/admin/users");
5
27
  await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
28
+ // Search by exact address — robust to pagination as the user count grows.
29
+ await page.locator("#toolbar-search").fill("admin@example.com");
30
+ await page.getByRole("button", { name: "Search", exact: true }).click();
6
31
  await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
7
- await page.getByPlaceholder("Search by email…").fill("user@");
32
+ await page.locator("#toolbar-search").fill("user@example.com");
33
+ await page.getByRole("button", { name: "Search", exact: true }).click();
8
34
  await expect(page.getByRole("cell", { name: "user@example.com" })).toBeVisible();
9
35
  await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
10
36
  });
11
37
 
38
+ test("user list shows the joined column @smoke", async ({ page }) => {
39
+ await ready(page, "/admin/users");
40
+ await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
41
+ });
42
+
12
43
  test("row menu exposes admin actions", async ({ page }) => {
13
44
  await page.goto("/admin/users");
14
- await page.getByPlaceholder("Search by email…").fill("user@example.com");
45
+ await page.locator("#toolbar-search").fill("user@example.com");
46
+ await page.getByRole("button", { name: "Search", exact: true }).click();
15
47
  const row = page.getByRole("row", { name: /user@example.com/ });
16
48
  await row.getByRole("button").click();
17
49
  await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
@@ -5,6 +5,12 @@
5
5
  "auth"
6
6
  ],
7
7
  "targetApp": "web",
8
+ "dependencies": {
9
+ "qrcode": "^1.5.4"
10
+ },
11
+ "devDependencies": {
12
+ "@types/qrcode": "^1.5.5"
13
+ },
8
14
  "env": [
9
15
  "# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
10
16
  "ADMIN_EMAILS=admin@example.com"
@@ -12,23 +18,23 @@
12
18
  "inject": [
13
19
  {
14
20
  "file": "apps/api/src/auth/auth.ts",
15
- "marker": "// podokit:auth-imports",
21
+ "marker": "// podokit:end:auth-imports",
16
22
  "text": "import { admin } from \"better-auth/plugins\";"
17
23
  },
18
24
  {
19
25
  "file": "apps/api/src/auth/auth.ts",
20
- "marker": "// podokit:auth-plugins",
21
- "text": "plugins.push(admin({ defaultRole: \"user\", adminRoles: [\"admin\"] }));"
26
+ "marker": "// podokit:end:auth-imports",
27
+ "text": "import { ac, roles } from \"./permissions\";"
22
28
  },
23
29
  {
24
30
  "file": "apps/api/src/auth/auth.ts",
25
- "marker": "// podokit:auth-email-password",
26
- "text": "sendResetPassword: async ({ user, url }) => {\n console.log(`[auth] password reset for ${user.email}: ${url}`);\n },"
31
+ "marker": "// podokit:end:auth-plugins",
32
+ "text": "plugins.push(admin({ defaultRole: \"user\", adminRoles: [\"admin\"], ac, roles }));"
27
33
  },
28
34
  {
29
35
  "file": "apps/api/src/auth/auth.ts",
30
- "marker": "// podokit:auth-options",
31
- "text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5173\").split(\",\").map((origin) => origin.trim()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
36
+ "marker": "// podokit:end:auth-options",
37
+ "text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").split(\",\").map((origin) => origin.trim()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
32
38
  }
33
39
  ],
34
40
  "instructions": [
@@ -8,12 +8,12 @@
8
8
  "API_KEYS=dev-key-please-change"
9
9
  ],
10
10
  "inject": [
11
- { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { ApiKeyModule } from \"./api-key/api-key.module\";" },
12
- { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "ApiKeyModule," }
11
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { ApiKeyModule } from \"./api-key/api-key.module\";" },
12
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:module-imports", "text": "ApiKeyModule," }
13
13
  ],
14
14
  "instructions": [
15
15
  "Set API_KEYS in .env (comma-separated).",
16
16
  "Protect a route for machine clients with @ApiKeyProtected() (bypasses the user session, requires X-API-Key).",
17
- "Call it: curl -H 'x-api-key: dev-key-please-change' localhost:3000/machine/ping"
17
+ "Call it: curl -H 'x-api-key: dev-key-please-change' localhost:5002/machine/ping"
18
18
  ]
19
19
  }
@@ -0,0 +1,16 @@
1
+ import { pool } from "../auth/db";
2
+ import { createConfigStore } from "@podosoft/podokit-auth";
3
+
4
+ // Audit logging is an admin-managed, DB-backed server toggle (auth_config `server`
5
+ // row, env fallback AUDIT_LOG_ENABLED), read through the shared config store's
6
+ // short TTL cache — the same mechanism as the other server-enforced toggles.
7
+ // Toggling it on the Settings page takes effect within the cache TTL, no restart.
8
+ const store = createConfigStore(pool);
9
+
10
+ export async function auditEnabled(): Promise<boolean> {
11
+ try {
12
+ return (await store.capabilitiesSnapshot()).auditLog;
13
+ } catch {
14
+ return true; // never let a config read failure silently drop the audit trail
15
+ }
16
+ }
@@ -1,5 +1,6 @@
1
1
  import { createAuthMiddleware } from "better-auth/api";
2
2
  import { recordAudit } from "./audit-events";
3
+ import { auditEnabled } from "./audit-enabled";
3
4
 
4
5
  // Map mutating auth/admin endpoints to semantic action codes. better-auth is
5
6
  // mounted as middleware and bypasses the NestJS interceptor, so these security
@@ -11,6 +12,8 @@ const ACTIONS: Record<string, string> = {
11
12
  "/change-password": "auth.password_change",
12
13
  "/change-email": "auth.email_change",
13
14
  "/reset-password": "auth.password_reset",
15
+ "/send-verification-email": "auth.verification_sent",
16
+ "/verify-email": "auth.email_verified",
14
17
  "/update-user": "account.update",
15
18
  "/delete-user": "account.delete",
16
19
  "/revoke-session": "session.revoke",
@@ -36,6 +39,7 @@ const ACTIONS: Record<string, string> = {
36
39
  export const auditAfterHook = createAuthMiddleware(async (ctx) => {
37
40
  const action = ACTIONS[ctx.path];
38
41
  if (!action) return;
42
+ if (!(await auditEnabled())) return;
39
43
  const context = ctx.context as {
40
44
  session?: { user?: { id?: string; name?: string; email?: string } };
41
45
  newSession?: { user?: { id?: string; name?: string; email?: string } };
@@ -6,6 +6,7 @@ import { type Observable, tap } from "rxjs";
6
6
  import { auth } from "../auth/auth";
7
7
  import { AUDIT_KEY, type AuditMeta } from "./audit.decorator";
8
8
  import { AuditService } from "./audit.service";
9
+ import { auditEnabled } from "./audit-enabled";
9
10
 
10
11
  // Records handlers explicitly marked with @Audit(...). Nothing is logged unless
11
12
  // you opt a route in, so the trail stays meaningful (semantic actions, not raw
@@ -25,6 +26,7 @@ export class AuditInterceptor implements NestInterceptor {
25
26
  }
26
27
 
27
28
  private async log(meta: AuditMeta, req: Request, result: unknown): Promise<void> {
29
+ if (!(await auditEnabled())) return;
28
30
  let actorId: string | null = null;
29
31
  let actorName: string | null = null;
30
32
  let actorEmail: string | null = null;
@@ -1,39 +1,45 @@
1
1
  {
2
2
  "name": "audit-log",
3
- "description": "Audit logging: records who did each mutating request (who/method/path/status) to an audit_logs table — a NestJS interceptor for your own API routes plus a better-auth hook for auth/admin actions (login, ban, role changes, ...). Requires auth.",
3
+ "description": "Audit logging: records who did each mutating request (who/method/path/status) to an audit_logs table \u2014 a NestJS interceptor for your own API routes plus a better-auth hook for auth/admin actions (login, ban, role changes, ...). Requires auth.",
4
4
  "requires": [
5
5
  "auth"
6
6
  ],
7
7
  "targetApp": "api",
8
8
  "env": [
9
- "# audit-log: surfaces the admin Audit log page in the dashboard",
9
+ "# audit-log: on/off is managed on the admin Settings page (stored in the DB,",
10
+ "# applied live, no restart). This env var is the fallback default used until an",
11
+ "# admin sets it \u2014 leave it 'true' so the trail is on out of the box; the DB value",
12
+ "# (Settings page) overrides it once set.",
10
13
  "AUDIT_LOG_ENABLED=true"
11
14
  ],
12
15
  "inject": [
13
16
  {
14
17
  "file": "apps/api/src/app.module.ts",
15
- "marker": "// podokit:imports",
18
+ "marker": "// podokit:end:imports",
16
19
  "text": "import { AuditModule } from \"./audit/audit.module\";"
17
20
  },
18
21
  {
19
22
  "file": "apps/api/src/app.module.ts",
20
- "marker": "// podokit:module-imports",
23
+ "marker": "// podokit:end:module-imports",
21
24
  "text": "AuditModule,"
22
25
  },
23
26
  {
24
27
  "file": "apps/api/src/auth/auth.ts",
25
- "marker": "// podokit:auth-imports",
28
+ "marker": "// podokit:end:auth-imports",
26
29
  "text": "import { auditAfterHook } from \"../audit/audit-hook\";"
27
30
  },
28
31
  {
29
32
  "file": "apps/api/src/auth/auth.ts",
30
- "marker": "// podokit:auth-options",
31
- "text": "hooks: { after: auditAfterHook },"
33
+ "marker": "// podokit:end:auth-hooks",
34
+ "text": "hooks.after = auditAfterHook;"
32
35
  }
33
36
  ],
34
37
  "instructions": [
35
38
  "Run migrations to create the audit_logs table: npm run migration:run -w <app>-api",
36
39
  "Auth/admin actions (login, ban, role changes, session revocation, user CRUD) are recorded via a better-auth hook; your own mutating NestJS routes are recorded by the interceptor.",
37
- "List recent entries (admin session required): curl -b cookies.txt localhost:3000/audit-logs"
38
- ]
40
+ "List recent entries (admin session required): curl -b cookies.txt localhost:5002/audit-logs"
41
+ ],
42
+ "dependencies": {
43
+ "@podosoft/podokit-auth": "^0.1.0"
44
+ }
39
45
  }
@@ -1,30 +1,95 @@
1
- import { Controller, Get } from "@nestjs/common";
2
- import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
1
+ import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from "@nestjs/common";
2
+ import type { Request } from "express";
3
+ import { fromNodeHeaders } from "better-auth/node";
4
+ import { Public, Session, type UserSession } from "@thallesp/nestjs-better-auth";
3
5
  import { ApiTags } from "@nestjs/swagger";
6
+ import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
7
+ import { ROLE_NAMES } from "../auth/permissions";
8
+ import { auth } from "../auth/auth";
9
+ import { pool } from "../auth/db";
10
+ import { createConfigStore } from "@podosoft/podokit-auth";
11
+ import type { Capabilities } from "@podosoft/podokit-contracts";
4
12
 
5
- type Capabilities = { twoFactor: boolean; providers: string[]; deleteAccount: boolean; auditLog: boolean };
13
+ // Reads OAuth/server-toggle state (DB-first, env fallback) for capabilities.
14
+ const configStore = createConfigStore(pool);
15
+
16
+ function isAdmin(session: UserSession): boolean {
17
+ const role = session.user?.role;
18
+ const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
19
+ return roles.includes("admin");
20
+ }
6
21
 
7
- // Protected by the global AuthGuard. Use @Session() to read the current user.
8
22
  @ApiTags("account")
9
23
  @Controller("account")
10
24
  export class AccountController {
25
+ constructor(private readonly settings: SettingsService) {}
26
+
11
27
  @Get("me")
12
28
  me(@Session() session: UserSession) {
13
29
  return session.user;
14
30
  }
15
31
 
16
32
  // Which optional auth features are enabled, so the UI can show/hide sections.
33
+ // Public so the login page (unauthenticated) can offer available sign-in methods.
34
+ @Public()
17
35
  @Get("capabilities")
18
- capabilities(): Capabilities {
19
- const providers = [
20
- process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ? "google" : null,
21
- process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET ? "github" : null,
22
- ].filter((p): p is string => p !== null);
36
+ async capabilities(): Promise<Capabilities> {
37
+ const flags = this.settings.flags();
38
+ // OAuth providers + server-enforced toggles are DB-backed (env fallback) and
39
+ // applied live, so read them from the config store rather than process.env.
40
+ const snapshot = await configStore.capabilitiesSnapshot();
23
41
  return {
24
- twoFactor: process.env.AUTH_TWO_FACTOR === "true",
25
- providers,
26
- deleteAccount: process.env.AUTH_ALLOW_DELETE === "true",
27
- auditLog: process.env.AUDIT_LOG_ENABLED === "true",
42
+ twoFactor: flags.twoFactor,
43
+ magicLink: flags.magicLink,
44
+ emailOtp: flags.emailOtp,
45
+ username: flags.username,
46
+ multiSession: flags.multiSession,
47
+ phoneNumber: flags.phoneNumber,
48
+ apiKey: flags.apiKey,
49
+ passkey: flags.passkey,
50
+ organization: flags.organization,
51
+ oidcProvider: flags.oidcProvider,
52
+ providers: snapshot.providers,
53
+ deleteAccount: snapshot.allowDelete,
54
+ emailVerification: snapshot.requireEmailVerification,
55
+ passwordBreachCheck: snapshot.passwordBreachCheck,
56
+ auditLog: snapshot.auditLog,
57
+ roles: ROLE_NAMES,
58
+ };
59
+ }
60
+
61
+ // Admin-only: toggle feature flags. Stored in the DB and reflected immediately in
62
+ // capabilities (UI). Server-enforced behaviours (email verification, breach check)
63
+ // are read by the auth server at startup, so those take effect on the next restart.
64
+ @Put("settings")
65
+ async updateSettings(
66
+ @Session() session: UserSession,
67
+ @Body() body: Partial<Record<FeatureFlag, boolean>>,
68
+ ): Promise<Record<FeatureFlag, boolean>> {
69
+ if (!isAdmin(session)) throw new ForbiddenException("Admins only");
70
+ const update: Partial<Record<FeatureFlag, boolean>> = {};
71
+ for (const flag of FEATURE_FLAGS) {
72
+ if (typeof body?.[flag] === "boolean") update[flag] = body[flag];
73
+ }
74
+ return this.settings.setMany(update);
75
+ }
76
+
77
+ // Add an existing user to an organization with a role (e.g. "manager"), so several
78
+ // members can hold it. better-auth's addMember is server-only; this exposes it and
79
+ // delegates org-level authorization to better-auth via the caller's session headers.
80
+ @Post("org-member")
81
+ async addOrgMember(
82
+ @Req() req: Request,
83
+ @Body() body: { organizationId: string; userId: string; role: string },
84
+ ): Promise<unknown> {
85
+ // addMember is a server-only better-auth endpoint (not on the typed public api);
86
+ // it exists at runtime, so reach it through a narrow cast.
87
+ const api = auth.api as unknown as {
88
+ addMember: (opts: { body: { organizationId: string; userId: string; role: string }; headers: Headers }) => Promise<unknown>;
28
89
  };
90
+ return api.addMember({
91
+ body: { organizationId: body.organizationId, userId: body.userId, role: body.role },
92
+ headers: fromNodeHeaders(req.headers),
93
+ });
29
94
  }
30
95
  }
@@ -1,5 +1,6 @@
1
1
  import { Module } from "@nestjs/common";
2
+ import { SettingsModule } from "../settings/settings.module";
2
3
  import { AccountController } from "./account.controller";
3
4
 
4
- @Module({ controllers: [AccountController] })
5
+ @Module({ imports: [SettingsModule], controllers: [AccountController] })
5
6
  export class AccountModule {}
@@ -0,0 +1,73 @@
1
+ import { auth as bootstrapAuth, buildAuth } from "./auth";
2
+ import { pool } from "./db";
3
+ import { createConfigStore } from "@podosoft/podokit-auth";
4
+
5
+ // Runtime auth instance that rebuilds when the DB config changes, so admin edits
6
+ // (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
7
+ //
8
+ // @thallesp/nestjs-better-auth reads `auth.handler` exactly once (via toNodeHandler)
9
+ // and `auth.api` per request. We hand it a Proxy: `.handler` is a STABLE delegating
10
+ // function (so the adapter's captured reference stays valid) that redispatches each
11
+ // request to the currently-built instance; every other access forwards to the
12
+ // current instance too. The guard/session behavior is unchanged (session tokens are
13
+ // signed with the stable BETTER_AUTH_SECRET; trustedOrigins is baked into every build).
14
+
15
+ const store = createConfigStore(pool);
16
+ const TTL_MS = 3_000;
17
+
18
+ let current = bootstrapAuth;
19
+ let currentVersion = "env"; // matches envAuthConfig() bootstrap
20
+ let checkedAt = 0;
21
+ let inflight: Promise<void> | null = null;
22
+
23
+ export function getAuth(): typeof bootstrapAuth {
24
+ return current;
25
+ }
26
+
27
+ /** Rebuild the instance if the DB config version changed. Cheap and throttled to
28
+ * TTL; single-flight so concurrent requests trigger at most one rebuild; keeps the
29
+ * last-good instance if loading/building fails, so auth never goes down. */
30
+ export async function refreshIfStale(): Promise<void> {
31
+ if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return;
32
+ checkedAt = Date.now();
33
+ let latest: string;
34
+ try {
35
+ latest = await store.currentVersion();
36
+ } catch {
37
+ return; // DB blip — keep serving the last-good instance
38
+ }
39
+ if (latest === currentVersion) return;
40
+ if (inflight) return inflight;
41
+ inflight = (async () => {
42
+ try {
43
+ const config = await store.load();
44
+ current = buildAuth(config);
45
+ currentVersion = config.version;
46
+ } catch (err) {
47
+ console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
48
+ } finally {
49
+ inflight = null;
50
+ }
51
+ })();
52
+ return inflight;
53
+ }
54
+
55
+ // Stable handler reference the adapter captures once; redispatches per request.
56
+ const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
57
+ await refreshIfStale();
58
+ return current.handler(request);
59
+ };
60
+
61
+ export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
62
+ get(_target, prop) {
63
+ if (prop === "handler") return dynamicHandler;
64
+ const value = Reflect.get(current, prop, current);
65
+ return typeof value === "function" ? value.bind(current) : value;
66
+ },
67
+ });
68
+
69
+ /** Optional boot preload so the first request doesn't pay the initial DB read. */
70
+ export async function primeAuth(): Promise<void> {
71
+ checkedAt = 0;
72
+ await refreshIfStale();
73
+ }