@podosoft/podokit 0.3.0 → 0.4.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 (151) hide show
  1. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +20 -2
  2. package/dist/templates/fullstack-nest-svelte/apps/api/tsconfig.json +14 -5
  3. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +3 -2
  4. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/api.ts +5 -0
  5. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +19 -0
  6. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +43 -4
  7. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +8 -3
  8. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +12 -0
  9. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
  10. package/dist/templates/fullstack-nest-svelte/dot-gitignore +5 -0
  11. package/dist/templates/fullstack-nest-svelte/package.json +11 -3
  12. package/dist/templates/fullstack-nest-svelte/tests/README.md +15 -0
  13. package/dist/templates/fullstack-nest-svelte/tests/api/health.api.spec.ts +14 -0
  14. package/dist/templates/fullstack-nest-svelte/tests/package.json +14 -0
  15. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +25 -0
  16. package/dist/templates/fullstack-nest-svelte/tests/ui/smoke.ui.spec.ts +8 -0
  17. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +23 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +20 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +59 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/language-switch.svelte +33 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +44 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +40 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +27 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte +7 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte +7 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/collapsible.svelte +11 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/collapsible/index.ts +13 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/index.ts +31 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-content.svelte +20 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte +22 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-item.svelte +14 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-link.svelte +42 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next-button.svelte +31 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-next.svelte +26 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-prev-button.svelte +31 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination-previous.svelte +26 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/pagination/pagination.svelte +28 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/index.ts +34 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-close.svelte +7 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-content.svelte +55 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-description.svelte +17 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte +20 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-header.svelte +20 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte +17 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte +7 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-title.svelte +17 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sheet/sheet.svelte +7 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/constants.ts +6 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/context.svelte.ts +81 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/index.ts +75 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte +24 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte +21 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte +33 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte +21 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte +33 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte +21 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte +21 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte +21 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte +20 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte +37 -0
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte +24 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +102 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte +21 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte +36 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +39 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte +21 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte +21 -0
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte +21 -0
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte +53 -0
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte +36 -0
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte +19 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte +36 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/ui/sidebar/sidebar.svelte +108 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/user-avatar.svelte +12 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/hooks/is-mobile.svelte.ts +9 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +27 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +359 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +14 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.svelte +50 -0
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +25 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.server.ts +5 -0
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +374 -0
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +8 -0
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +78 -0
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +8 -0
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +122 -0
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +9 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +406 -0
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +13 -0
  91. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +54 -0
  92. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +59 -0
  93. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +51 -0
  94. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +60 -0
  95. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +22 -0
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +20 -0
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +22 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +48 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +43 -0
  100. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +134 -0
  101. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +6 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +9 -0
  103. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +26 -0
  104. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +29 -0
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -0
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +16 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +45 -0
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +25 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -0
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +13 -0
  111. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +15 -0
  112. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +16 -0
  113. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +129 -0
  114. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +19 -0
  115. package/dist/templates/modules/admin-dashboard/module.manifest.json +41 -0
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +59 -0
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +34 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +70 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-log.entity.ts +29 -8
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.controller.ts +7 -2
  121. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.decorator.ts +23 -0
  122. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +36 -21
  123. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +2 -1
  124. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +36 -0
  125. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +10 -4
  126. package/dist/templates/modules/audit-log/module.manifest.json +30 -7
  127. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +17 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +26 -3
  129. package/dist/templates/todo/apps/api/package.json +20 -2
  130. package/dist/templates/todo/apps/api/tsconfig.json +14 -5
  131. package/dist/templates/todo/apps/web/package.json +3 -2
  132. package/dist/templates/todo/apps/web/src/lib/api.ts +5 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/api.ts +19 -0
  134. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +43 -4
  135. package/dist/templates/todo/apps/web/src/routes/+page.svelte +19 -18
  136. package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +12 -0
  137. package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +12 -0
  138. package/dist/templates/todo/dot-gitignore +5 -0
  139. package/dist/templates/todo/package.json +11 -3
  140. package/dist/templates/todo/tests/README.md +15 -0
  141. package/dist/templates/todo/tests/api/health.api.spec.ts +14 -0
  142. package/dist/templates/todo/tests/package.json +14 -0
  143. package/dist/templates/todo/tests/playwright.config.ts +25 -0
  144. package/dist/templates/todo/tests/ui/smoke.ui.spec.ts +8 -0
  145. package/package.json +1 -1
  146. package/dist/templates/fullstack-nest-svelte/apps/api/test/health.e2e-spec.ts +0 -23
  147. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/health/+server.ts +0 -12
  148. package/dist/templates/todo/apps/api/test/health.e2e-spec.ts +0 -23
  149. package/dist/templates/todo/apps/web/src/routes/api/health/+server.ts +0 -12
  150. package/dist/templates/todo/apps/web/src/routes/api/todos/+server.ts +0 -24
  151. package/dist/templates/todo/apps/web/src/routes/api/todos/[id]/+server.ts +0 -24
@@ -8,15 +8,21 @@ export class InitAuditLogs1720300000000 implements MigrationInterface {
8
8
  await queryRunner.query(`
9
9
  CREATE TABLE "audit_logs" (
10
10
  "id" uuid NOT NULL DEFAULT gen_random_uuid(),
11
- "userId" character varying,
12
- "method" character varying(10) NOT NULL,
13
- "path" character varying(2048) NOT NULL,
14
- "statusCode" integer NOT NULL,
11
+ "action" character varying(128) NOT NULL,
12
+ "actorId" character varying,
13
+ "actorName" character varying,
14
+ "actorEmail" character varying,
15
+ "targetType" character varying,
16
+ "targetId" character varying,
17
+ "targetLabel" character varying,
15
18
  "ip" character varying,
19
+ "metadata" jsonb,
16
20
  "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
17
21
  CONSTRAINT "PK_audit_logs_id" PRIMARY KEY ("id")
18
22
  )
19
23
  `);
24
+ await queryRunner.query(`CREATE INDEX "IDX_audit_logs_action" ON "audit_logs" ("action")`);
25
+ await queryRunner.query(`CREATE INDEX "IDX_audit_logs_createdAt" ON "audit_logs" ("createdAt")`);
20
26
  }
21
27
 
22
28
  async down(queryRunner: QueryRunner): Promise<void> {
@@ -1,16 +1,39 @@
1
1
  {
2
2
  "name": "audit-log",
3
- "description": "Audit logging: a global interceptor records who did each mutating request (who/method/path/status) to an audit_logs table. Requires auth.",
4
- "requires": ["auth"],
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.",
4
+ "requires": [
5
+ "auth"
6
+ ],
5
7
  "targetApp": "api",
6
- "env": [],
8
+ "env": [
9
+ "# audit-log: surfaces the admin Audit log page in the dashboard",
10
+ "AUDIT_LOG_ENABLED=true"
11
+ ],
7
12
  "inject": [
8
- { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { AuditModule } from \"./audit/audit.module\";" },
9
- { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "AuditModule," }
13
+ {
14
+ "file": "apps/api/src/app.module.ts",
15
+ "marker": "// podokit:imports",
16
+ "text": "import { AuditModule } from \"./audit/audit.module\";"
17
+ },
18
+ {
19
+ "file": "apps/api/src/app.module.ts",
20
+ "marker": "// podokit:module-imports",
21
+ "text": "AuditModule,"
22
+ },
23
+ {
24
+ "file": "apps/api/src/auth/auth.ts",
25
+ "marker": "// podokit:auth-imports",
26
+ "text": "import { auditAfterHook } from \"../audit/audit-hook\";"
27
+ },
28
+ {
29
+ "file": "apps/api/src/auth/auth.ts",
30
+ "marker": "// podokit:auth-options",
31
+ "text": "hooks: { after: auditAfterHook },"
32
+ }
10
33
  ],
11
34
  "instructions": [
12
35
  "Run migrations to create the audit_logs table: npm run migration:run -w <app>-api",
13
- "Mutating requests (POST/PUT/PATCH/DELETE) are recorded with the acting user.",
14
- "List recent entries (requires a session): curl -b cookies.txt localhost:3000/audit-logs"
36
+ "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"
15
38
  ]
16
39
  }
@@ -2,6 +2,8 @@ import { Controller, Get } from "@nestjs/common";
2
2
  import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
3
3
  import { ApiTags } from "@nestjs/swagger";
4
4
 
5
+ type Capabilities = { twoFactor: boolean; providers: string[]; deleteAccount: boolean; auditLog: boolean };
6
+
5
7
  // Protected by the global AuthGuard. Use @Session() to read the current user.
6
8
  @ApiTags("account")
7
9
  @Controller("account")
@@ -10,4 +12,19 @@ export class AccountController {
10
12
  me(@Session() session: UserSession) {
11
13
  return session.user;
12
14
  }
15
+
16
+ // Which optional auth features are enabled, so the UI can show/hide sections.
17
+ @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);
23
+ 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",
28
+ };
29
+ }
13
30
  }
@@ -1,6 +1,7 @@
1
- import { betterAuth, type BetterAuthOptions } from "better-auth";
1
+ import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
2
2
  import { twoFactor } from "better-auth/plugins";
3
3
  import { Pool } from "pg";
4
+ // podokit:auth-imports
4
5
 
5
6
  // OAuth providers are enabled only when their credentials are present.
6
7
  function socialProviders(): NonNullable<BetterAuthOptions["socialProviders"]> {
@@ -20,6 +21,13 @@ function socialProviders(): NonNullable<BetterAuthOptions["socialProviders"]> {
20
21
  return providers;
21
22
  }
22
23
 
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
30
+
23
31
  export const auth = betterAuth({
24
32
  database: new Pool({
25
33
  host: process.env.POSTGRES_HOST ?? "localhost",
@@ -28,9 +36,24 @@ export const auth = betterAuth({
28
36
  password: process.env.POSTGRES_PASSWORD ?? "podokit",
29
37
  database: process.env.POSTGRES_DB ?? "podokit",
30
38
  }),
31
- emailAndPassword: { enabled: true },
39
+ emailAndPassword: {
40
+ enabled: true,
41
+ // podokit:auth-email-password
42
+ },
32
43
  socialProviders: socialProviders(),
33
- plugins: process.env.AUTH_TWO_FACTOR === "true" ? [twoFactor()] : [],
44
+ plugins,
34
45
  secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
35
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"],
52
+ },
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
36
59
  });
@@ -7,7 +7,7 @@
7
7
  "build": "nest build",
8
8
  "start": "node dist/main",
9
9
  "lint": "tsc -p tsconfig.json --noEmit",
10
- "test": "jest",
10
+ "test": "jest --passWithNoTests",
11
11
  "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
12
12
  "migration:run": "npm run typeorm -- migration:run",
13
13
  "migration:revert": "npm run typeorm -- migration:revert",
@@ -39,6 +39,24 @@
39
39
  "supertest": "^7.0.0",
40
40
  "ts-jest": "^29.2.5",
41
41
  "ts-node": "^10.9.2",
42
- "typescript": "^5.6.3"
42
+ "typescript": "^6.0.3"
43
+ },
44
+ "jest": {
45
+ "moduleFileExtensions": [
46
+ "js",
47
+ "json",
48
+ "ts"
49
+ ],
50
+ "rootDir": "src",
51
+ "testRegex": ".*\\.spec\\.ts$",
52
+ "transform": {
53
+ "^.+\\.ts$": [
54
+ "ts-jest",
55
+ {
56
+ "tsconfig": "tsconfig.json"
57
+ }
58
+ ]
59
+ },
60
+ "testEnvironment": "node"
43
61
  }
44
62
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "module": "CommonJS",
3
+ "module": "nodenext",
4
4
  "target": "ES2022",
5
- "moduleResolution": "Node",
5
+ "moduleResolution": "nodenext",
6
6
  "outDir": "dist",
7
7
  "rootDir": "src",
8
8
  "declaration": false,
@@ -14,8 +14,17 @@
14
14
  "noImplicitReturns": true,
15
15
  "esModuleInterop": true,
16
16
  "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true
17
+ "forceConsistentCasingInFileNames": true,
18
+ "types": [
19
+ "node"
20
+ ]
18
21
  },
19
- "include": ["src/**/*.ts"],
20
- "exclude": ["node_modules", "dist", "test"]
22
+ "include": [
23
+ "src/**/*.ts"
24
+ ],
25
+ "exclude": [
26
+ "node_modules",
27
+ "dist",
28
+ "test"
29
+ ]
21
30
  }
@@ -8,11 +8,12 @@
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "lint": "svelte-check --tsconfig ./tsconfig.json",
11
- "test": "echo \"add Playwright tests here\" && exit 0"
11
+ "test": "echo \"unit: add vitest; e2e: npm run test:e2e (see tests/)\" && exit 0"
12
12
  },
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
+ "@podosoft/podokit-api-client": "^0.4.0",
16
17
  "bits-ui": "^2.18.1",
17
18
  "clsx": "^2.1.1",
18
19
  "mode-watcher": "^1.1.0",
@@ -29,7 +30,7 @@
29
30
  "svelte-check": "^4.0.0",
30
31
  "tailwindcss": "^4.0.0",
31
32
  "typesafe-i18n": "^5.26.2",
32
- "typescript": "^5.6.3",
33
+ "typescript": "^6.0.3",
33
34
  "vite": "^5.4.0",
34
35
  "tw-animate-css": "^1.4.0"
35
36
  }
@@ -0,0 +1,5 @@
1
+ import { createApiClient } from "@podosoft/podokit-api-client";
2
+
3
+ // Browser client: same-origin. REST calls go to /api/* and auth to /api/auth/*,
4
+ // both proxied to the backend by the SvelteKit server (see routes/api/**).
5
+ export const api = createApiClient();
@@ -0,0 +1,19 @@
1
+ import type { RequestEvent } from "@sveltejs/kit";
2
+ import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
3
+ import { backendBaseUrl } from "./backend-proxy";
4
+
5
+ // Server-side client for load functions and hooks: points at the internal
6
+ // backend URL and forwards the incoming request's cookies.
7
+ export function serverApiClient(event: RequestEvent): ApiClient {
8
+ const cookie = event.request.headers.get("cookie") ?? "";
9
+ const fetchWithCookies: typeof fetch = (input, init) => {
10
+ const headers = new Headers(init?.headers);
11
+ if (cookie) headers.set("cookie", cookie);
12
+ return event.fetch(input, { ...init, headers });
13
+ };
14
+ return createApiClient({
15
+ baseUrl: backendBaseUrl(),
16
+ apiBasePath: "",
17
+ fetch: fetchWithCookies,
18
+ });
19
+ }
@@ -1,16 +1,55 @@
1
1
  // Server-side proxy boundary. The browser never talks to the API directly:
2
- // only these allowlisted headers are forwarded to BACKEND_INTERNAL_URL.
3
- const FORWARDED_HEADERS = ["authorization", "cookie", "content-type"];
2
+ // requests go through SvelteKit server routes, which forward an allowlist of
3
+ // headers to BACKEND_INTERNAL_URL and relay the response (including Set-Cookie).
4
+ const FORWARDED_HEADERS = ["authorization", "cookie", "content-type", "accept", "origin", "referer"];
5
+ const RELAYED_RESPONSE_HEADERS = ["content-type", "location", "cache-control"];
4
6
 
5
7
  export function backendBaseUrl(): string {
6
8
  return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
7
9
  }
8
10
 
9
- export function backendProxyHeaders(request: Request): Headers {
11
+ // Normalize the resolved client IP for the forwarded header. better-auth stores
12
+ // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
13
+ // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
14
+ // unwrap IPv4-mapped addresses.
15
+ function normalizeClientIp(address: string | undefined): string | undefined {
16
+ if (!address) return undefined;
17
+ if (address === "::1" || address === "::") return "127.0.0.1";
18
+ if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
19
+ return address;
20
+ }
21
+
22
+ export async function proxyRequest(
23
+ request: Request,
24
+ targetUrl: string,
25
+ clientAddress?: string,
26
+ ): Promise<Response> {
10
27
  const headers = new Headers();
11
28
  for (const name of FORWARDED_HEADERS) {
12
29
  const value = request.headers.get(name);
13
30
  if (value) headers.set(name, value);
14
31
  }
15
- return headers;
32
+ // Forward the resolved client IP so the API (better-auth) can record it.
33
+ const forwardedIp = normalizeClientIp(clientAddress);
34
+ if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
35
+
36
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
37
+ const upstream = await fetch(targetUrl, {
38
+ method: request.method,
39
+ headers,
40
+ body: hasBody ? await request.text() : undefined,
41
+ redirect: "manual",
42
+ });
43
+
44
+ const responseHeaders = new Headers();
45
+ for (const name of RELAYED_RESPONSE_HEADERS) {
46
+ const value = upstream.headers.get(name);
47
+ if (value) responseHeaders.set(name, value);
48
+ }
49
+ // Relay Set-Cookie so auth session cookies reach the browser.
50
+ for (const cookie of upstream.headers.getSetCookie()) {
51
+ responseHeaders.append("set-cookie", cookie);
52
+ }
53
+
54
+ return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
16
55
  }
@@ -3,6 +3,8 @@
3
3
  import { Input } from "$lib/components/ui/input";
4
4
  import { Checkbox } from "$lib/components/ui/checkbox";
5
5
  import * as Card from "$lib/components/ui/card";
6
+ import { api } from "$lib/api";
7
+ import { ApiError } from "@podosoft/podokit-api-client";
6
8
 
7
9
  type Todo = { id: string; title: string; completed: boolean };
8
10
 
@@ -10,40 +12,39 @@
10
12
  let title = $state("");
11
13
  let error = $state<string | null>(null);
12
14
 
15
+ function fail(err: unknown, action: string): void {
16
+ error = err instanceof ApiError ? `${action}: ${err.message}` : `${action} failed`;
17
+ }
18
+
13
19
  async function load(): Promise<void> {
14
- const res = await fetch("/api/todos");
15
- if (res.ok) todos = await res.json();
16
- else error = `Failed to load (HTTP ${res.status})`;
20
+ try {
21
+ todos = await api.get<Todo[]>("/todos");
22
+ error = null;
23
+ } catch (err) {
24
+ fail(err, "Load");
25
+ }
17
26
  }
18
27
 
19
28
  async function add(event: SubmitEvent): Promise<void> {
20
29
  event.preventDefault();
21
30
  const value = title.trim();
22
31
  if (!value) return;
23
- const res = await fetch("/api/todos", {
24
- method: "POST",
25
- headers: { "content-type": "application/json" },
26
- body: JSON.stringify({ title: value }),
27
- });
28
- if (res.ok) {
32
+ try {
33
+ await api.post("/todos", { title: value });
29
34
  title = "";
30
35
  await load();
31
- } else {
32
- error = `Failed to add (HTTP ${res.status})`;
36
+ } catch (err) {
37
+ fail(err, "Add");
33
38
  }
34
39
  }
35
40
 
36
41
  async function toggle(todo: Todo): Promise<void> {
37
- await fetch(`/api/todos/${todo.id}`, {
38
- method: "PATCH",
39
- headers: { "content-type": "application/json" },
40
- body: JSON.stringify({ completed: !todo.completed }),
41
- });
42
+ await api.patch(`/todos/${todo.id}`, { completed: !todo.completed });
42
43
  await load();
43
44
  }
44
45
 
45
46
  async function remove(todo: Todo): Promise<void> {
46
- await fetch(`/api/todos/${todo.id}`, { method: "DELETE" });
47
+ await api.del(`/todos/${todo.id}`);
47
48
  await load();
48
49
  }
49
50
 
@@ -61,7 +62,7 @@
61
62
  <Card.Root>
62
63
  <Card.Header>
63
64
  <Card.Title>Todos</Card.Title>
64
- <Card.Description>A NestJS + SvelteKit CRUD example.</Card.Description>
65
+ <Card.Description>A NestJS + SvelteKit CRUD example via the typed ApiClient.</Card.Description>
65
66
  </Card.Header>
66
67
  <Card.Content class="flex flex-col gap-4">
67
68
  <form class="flex gap-2" onsubmit={add}>
@@ -0,0 +1,12 @@
1
+ import type { RequestHandler } from "@sveltejs/kit";
2
+ import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
3
+
4
+ // Proxy the app's REST API, stripping the /api prefix (backend routes are at root).
5
+ const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
+ proxyRequest(request, `${backendBaseUrl()}/${params.path}${url.search}`, getClientAddress());
7
+
8
+ export const GET = handler;
9
+ export const POST = handler;
10
+ export const PUT = handler;
11
+ export const PATCH = handler;
12
+ export const DELETE = handler;
@@ -0,0 +1,12 @@
1
+ import type { RequestHandler } from "@sveltejs/kit";
2
+ import { backendBaseUrl, proxyRequest } from "$lib/server/backend-proxy";
3
+
4
+ // Proxy the better-auth handler, keeping the /api/auth prefix.
5
+ const handler: RequestHandler = ({ request, params, url, getClientAddress }) =>
6
+ proxyRequest(request, `${backendBaseUrl()}/api/auth/${params.all}${url.search}`, getClientAddress());
7
+
8
+ export const GET = handler;
9
+ export const POST = handler;
10
+ export const PUT = handler;
11
+ export const PATCH = handler;
12
+ export const DELETE = handler;
@@ -7,3 +7,8 @@ build/
7
7
  !.env.example
8
8
  .DS_Store
9
9
  *.log
10
+
11
+ # e2e test artifacts
12
+ tests/playwright/
13
+ tests/playwright-report/
14
+ tests/test-results/
@@ -2,12 +2,20 @@
2
2
  "name": "{{projectName}}",
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
- "engines": { "node": ">=20" },
6
- "workspaces": ["apps/*"],
5
+ "engines": {
6
+ "node": ">=20"
7
+ },
8
+ "workspaces": [
9
+ "apps/*",
10
+ "tests"
11
+ ],
7
12
  "scripts": {
8
13
  "dev": "npm run dev --workspaces --if-present",
9
14
  "build": "npm run build --workspaces --if-present",
10
15
  "lint": "npm run lint --workspaces --if-present",
11
- "test": "npm run test --workspaces --if-present"
16
+ "test": "npm run test --workspaces --if-present",
17
+ "test:e2e": "npm run test:e2e -w tests",
18
+ "test:e2e:ui": "npm run test:e2e:ui -w tests",
19
+ "test:e2e:api": "npm run test:e2e:api -w tests"
12
20
  }
13
21
  }
@@ -0,0 +1,15 @@
1
+ # tests
2
+
3
+ End-to-end tests for this app, run with [`@playwright/test`](https://playwright.dev):
4
+
5
+ ```bash
6
+ npm run test:e2e # all specs (api + ui)
7
+ npm run test:e2e:api # backend endpoints (request-only)
8
+ npm run test:e2e:ui # pages (chromium)
9
+ npm run test:e2e:report # open the HTML report
10
+ ```
11
+
12
+ The suite runs against a live stack (web on `E2E_BASE_URL`, default
13
+ `http://localhost:5173`, proxying `/api/*` to the API). Start PostgreSQL, run the
14
+ auth migration, and start the API + web first. Files ending in `*.api.spec.ts` run
15
+ in the request-only `api` project; `*.ui.spec.ts` run in the chromium `ui` project.
@@ -0,0 +1,14 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ // Health endpoints are public and present in every generated app.
4
+ test("GET /health is public and ok @smoke", async ({ request }) => {
5
+ const res = await request.get("/api/health");
6
+ expect(res.ok()).toBeTruthy();
7
+ expect((await res.json()).status).toBe("ok");
8
+ });
9
+
10
+ test("GET /health/ready reports the database", async ({ request }) => {
11
+ const res = await request.get("/api/health/ready");
12
+ expect(res.status()).toBe(200);
13
+ expect((await res.json()).status).toBe("ready");
14
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "{{projectName}}-tests",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "test:e2e": "playwright test",
7
+ "test:e2e:ui": "playwright test --project=ui",
8
+ "test:e2e:api": "playwright test --project=api",
9
+ "test:e2e:report": "playwright show-report"
10
+ },
11
+ "devDependencies": {
12
+ "@playwright/test": "^1.61.1"
13
+ }
14
+ }
@@ -0,0 +1,25 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ // The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
4
+ // the API). Start it first (see docs/testing.md) — locally via the dev harness,
5
+ // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
6
+ // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
7
+ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173";
8
+
9
+ export default defineConfig({
10
+ testDir: ".",
11
+ fullyParallel: true,
12
+ forbidOnly: !!process.env.CI,
13
+ retries: process.env.CI ? 2 : 0,
14
+ workers: process.env.CI ? 1 : undefined,
15
+ reporter: [["list"], ["html", { open: "never" }]],
16
+ use: {
17
+ baseURL,
18
+ trace: "on-first-retry",
19
+ screenshot: "only-on-failure",
20
+ },
21
+ projects: [
22
+ { name: "api", testMatch: /.*\.api\.spec\.ts/ },
23
+ { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
24
+ ],
25
+ });
@@ -0,0 +1,8 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ // Module-agnostic: the app responds at "/" (a page or a redirect to it).
4
+ test("app is reachable @smoke", async ({ page }) => {
5
+ const res = await page.goto("/");
6
+ expect(res?.status()).toBeLessThan(400);
7
+ await expect(page.locator("body")).toBeVisible();
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,23 +0,0 @@
1
- import { Test } from "@nestjs/testing";
2
- import type { INestApplication } from "@nestjs/common";
3
- import request from "supertest";
4
- import { HealthModule } from "../src/health/health.module";
5
-
6
- describe("Health (e2e)", () => {
7
- let app: INestApplication;
8
-
9
- beforeAll(async () => {
10
- const moduleRef = await Test.createTestingModule({ imports: [HealthModule] }).compile();
11
- app = moduleRef.createNestApplication();
12
- await app.init();
13
- });
14
-
15
- afterAll(async () => {
16
- await app.close();
17
- });
18
-
19
- it("GET /health returns ok", async () => {
20
- const res = await request(app.getHttpServer()).get("/health").expect(200);
21
- expect(res.body.status).toBe("ok");
22
- });
23
- });
@@ -1,12 +0,0 @@
1
- import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, backendProxyHeaders } from "$lib/server/backend-proxy";
3
-
4
- export const GET: RequestHandler = async ({ request }) => {
5
- const upstream = await fetch(`${backendBaseUrl()}/health`, {
6
- headers: backendProxyHeaders(request),
7
- });
8
- return new Response(await upstream.text(), {
9
- status: upstream.status,
10
- headers: { "content-type": "application/json" },
11
- });
12
- };
@@ -1,23 +0,0 @@
1
- import { Test } from "@nestjs/testing";
2
- import type { INestApplication } from "@nestjs/common";
3
- import request from "supertest";
4
- import { HealthModule } from "../src/health/health.module";
5
-
6
- describe("Health (e2e)", () => {
7
- let app: INestApplication;
8
-
9
- beforeAll(async () => {
10
- const moduleRef = await Test.createTestingModule({ imports: [HealthModule] }).compile();
11
- app = moduleRef.createNestApplication();
12
- await app.init();
13
- });
14
-
15
- afterAll(async () => {
16
- await app.close();
17
- });
18
-
19
- it("GET /health returns ok", async () => {
20
- const res = await request(app.getHttpServer()).get("/health").expect(200);
21
- expect(res.body.status).toBe("ok");
22
- });
23
- });
@@ -1,12 +0,0 @@
1
- import type { RequestHandler } from "@sveltejs/kit";
2
- import { backendBaseUrl, backendProxyHeaders } from "$lib/server/backend-proxy";
3
-
4
- export const GET: RequestHandler = async ({ request }) => {
5
- const upstream = await fetch(`${backendBaseUrl()}/health`, {
6
- headers: backendProxyHeaders(request),
7
- });
8
- return new Response(await upstream.text(), {
9
- status: upstream.status,
10
- headers: { "content-type": "application/json" },
11
- });
12
- };