@podosoft/podokit 0.12.3 → 0.13.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 (51) hide show
  1. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  2. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  3. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
  4. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
  5. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +8 -1
  6. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +8 -1
  7. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +8 -1
  8. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +73 -2
  9. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
  10. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
  11. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
  12. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
  13. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
  14. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
  15. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +6 -6
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +2 -2
  20. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
  21. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  22. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
  23. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +2 -2
  24. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
  25. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  26. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
  28. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  29. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +32 -2
  31. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
  32. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
  33. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
  38. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -0
  39. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  40. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
  43. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -1
  44. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
  45. package/dist/templates/modules/auth/module.manifest.json +5 -4
  46. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  47. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  48. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  49. package/dist/templates/todo/apps/api/package.json +1 -1
  50. package/dist/templates/todo/apps/web/package.json +1 -1
  51. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  import { auth as bootstrapAuth, buildAuth } from "./auth";
2
- import { pool } from "./db";
3
- import { createConfigStore } from "@podosoft/podokit-auth";
2
+ import { authConfigStore } from "./auth-config-store";
4
3
 
5
4
  // Runtime auth instance that rebuilds when the DB config changes, so admin edits
6
5
  // (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
@@ -12,7 +11,6 @@ import { createConfigStore } from "@podosoft/podokit-auth";
12
11
  // current instance too. The guard/session behavior is unchanged (session tokens are
13
12
  // signed with the stable BETTER_AUTH_SECRET; trustedOrigins is baked into every build).
14
13
 
15
- const store = createConfigStore(pool);
16
14
  const TTL_MS = 3_000;
17
15
 
18
16
  let current = bootstrapAuth;
@@ -32,7 +30,7 @@ export async function refreshIfStale(): Promise<void> {
32
30
  checkedAt = Date.now();
33
31
  let latest: string;
34
32
  try {
35
- latest = await store.currentVersion();
33
+ latest = await authConfigStore.currentVersion();
36
34
  } catch {
37
35
  return; // DB blip — keep serving the last-good instance
38
36
  }
@@ -40,7 +38,7 @@ export async function refreshIfStale(): Promise<void> {
40
38
  if (inflight) return inflight;
41
39
  inflight = (async () => {
42
40
  try {
43
- const config = await store.load();
41
+ const config = await authConfigStore.load();
44
42
  current = buildAuth(config);
45
43
  currentVersion = config.version;
46
44
  } catch (err) {
@@ -52,6 +50,15 @@ export async function refreshIfStale(): Promise<void> {
52
50
  return inflight;
53
51
  }
54
52
 
53
+ /** Apply a successful in-process admin write before the response is returned. */
54
+ export async function refreshAuthNow(): Promise<void> {
55
+ authConfigStore.invalidate();
56
+ checkedAt = 0;
57
+ // Force a rebuild even if two writes receive the same millisecond timestamp.
58
+ currentVersion = `stale:${Date.now()}`;
59
+ await refreshIfStale();
60
+ }
61
+
55
62
  // Stable handler reference the adapter captures once; redispatches per request.
56
63
  const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
57
64
  await refreshIfStale();
@@ -6,7 +6,7 @@ import { sendSms } from "../sms/sms";
6
6
  import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
7
7
  import { orgAc, orgRoles } from "./org-permissions";
8
8
  import { pool } from "./db";
9
- import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
9
+ import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
10
10
  import { apiKey } from "@better-auth/api-key";
11
11
  import { passkey } from "@better-auth/passkey";
12
12
  import { oauthProvider } from "@better-auth/oauth-provider";
@@ -176,6 +176,8 @@ export function buildAuth(config: AuthConfig) {
176
176
  // podokit:begin:auth-hooks
177
177
  // podokit:end:auth-hooks
178
178
 
179
+ const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
180
+
179
181
  return betterAuth({
180
182
  database: pool,
181
183
  emailAndPassword: {
@@ -208,6 +210,7 @@ export function buildAuth(config: AuthConfig) {
208
210
  },
209
211
  },
210
212
  socialProviders: buildSocial(config),
213
+ ...(session ? { session } : {}),
211
214
  plugins,
212
215
  hooks,
213
216
  secret: process.env.BETTER_AUTH_SECRET ?? "change-me-in-production-min-32-characters",
@@ -2,7 +2,17 @@ import { BadRequestException, Injectable } from "@nestjs/common";
2
2
  import { InjectRepository } from "@nestjs/typeorm";
3
3
  import { Repository } from "typeorm";
4
4
  import { AuthConfigRow } from "./auth-config.entity";
5
- import { encryptSecret, envAuthConfig, socialKey, SUPPORTED_PROVIDER_IDS, SUPPORTED_SOCIAL_PROVIDERS } from "@podosoft/podokit-auth";
5
+ import {
6
+ DEFAULT_SESSION_LIFETIME_SECONDS,
7
+ encryptSecret,
8
+ envAuthConfig,
9
+ isSessionIdleTimeoutMinutes,
10
+ resolveSessionIdleTimeoutMinutes,
11
+ socialKey,
12
+ SUPPORTED_PROVIDER_IDS,
13
+ SUPPORTED_SOCIAL_PROVIDERS,
14
+ } from "@podosoft/podokit-auth";
15
+ import { refreshAuthNow } from "../auth/auth-provider";
6
16
 
7
17
  /** One configured social provider as shown to the admin (no secret — just a
8
18
  * `hasSecret` flag). */
@@ -16,7 +26,14 @@ export type AuthConfigView = {
16
26
  /** Catalog of addable providers (id + display label) for the "add provider" picker. */
17
27
  catalog: ReadonlyArray<{ id: string; label: string }>;
18
28
  smtp: { enabled: boolean; host: string; port: number; secure: boolean; user: string; from: string; hasSecret: boolean };
19
- server: { requireEmailVerification: boolean; requireSignupApproval: boolean; allowDelete: boolean; hibp: boolean; auditLog: boolean };
29
+ server: {
30
+ requireEmailVerification: boolean;
31
+ requireSignupApproval: boolean;
32
+ allowDelete: boolean;
33
+ hibp: boolean;
34
+ auditLog: boolean;
35
+ sessionIdleTimeoutMinutes: number | null;
36
+ };
20
37
  };
21
38
 
22
39
  type ProviderUpdate = { enabled?: boolean; clientId?: string; clientSecret?: string; redirectURI?: string; delete?: boolean };
@@ -24,7 +41,14 @@ export type AuthConfigUpdate = {
24
41
  /** Per-provider upsert (keyed by provider id); `{ delete: true }` removes it. */
25
42
  social?: Record<string, ProviderUpdate>;
26
43
  smtp?: { enabled?: boolean; host?: string; port?: number; secure?: boolean; user?: string; pass?: string; from?: string };
27
- server?: { requireEmailVerification?: boolean; requireSignupApproval?: boolean; allowDelete?: boolean; hibp?: boolean; auditLog?: boolean };
44
+ server?: {
45
+ requireEmailVerification?: boolean;
46
+ requireSignupApproval?: boolean;
47
+ allowDelete?: boolean;
48
+ hibp?: boolean;
49
+ auditLog?: boolean;
50
+ sessionIdleTimeoutMinutes?: number | null;
51
+ };
28
52
  };
29
53
 
30
54
  @Injectable()
@@ -59,6 +83,7 @@ export class AuthConfigService {
59
83
  allowDelete?: boolean;
60
84
  hibp?: boolean;
61
85
  auditLog?: boolean;
86
+ sessionIdleTimeoutMinutes?: number | null;
62
87
  };
63
88
  return {
64
89
  social,
@@ -78,6 +103,10 @@ export class AuthConfigService {
78
103
  allowDelete: serverC.allowDelete ?? env.allowDelete,
79
104
  hibp: serverC.hibp ?? env.hibp,
80
105
  auditLog: serverC.auditLog ?? env.auditLog,
106
+ sessionIdleTimeoutMinutes: resolveSessionIdleTimeoutMinutes(
107
+ serverC.sessionIdleTimeoutMinutes,
108
+ env.sessionIdleTimeoutMinutes,
109
+ ),
81
110
  },
82
111
  };
83
112
  }
@@ -90,6 +119,10 @@ export class AuthConfigService {
90
119
  }
91
120
  if (dto.smtp) await this.upsertSmtp(dto.smtp);
92
121
  if (dto.server) await this.upsertServer(dto.server);
122
+ await refreshAuthNow();
123
+ if (dto.server?.sessionIdleTimeoutMinutes !== undefined) {
124
+ await this.resetSessionExpirations(dto.server.sessionIdleTimeoutMinutes);
125
+ }
93
126
  return this.describe();
94
127
  }
95
128
 
@@ -116,12 +149,31 @@ export class AuthConfigService {
116
149
  }
117
150
 
118
151
  private async upsertServer(u: NonNullable<AuthConfigUpdate["server"]>): Promise<void> {
152
+ if (u.sessionIdleTimeoutMinutes !== undefined && !isSessionIdleTimeoutMinutes(u.sessionIdleTimeoutMinutes)) {
153
+ throw new BadRequestException("Session idle timeout must be null or an integer from 5 to 10080 minutes");
154
+ }
119
155
  const row = (await this.repo.findOneBy({ key: "server" })) ?? this.repo.create({ key: "server", enabled: true, config: {}, secret: null });
120
156
  const config = { ...(row.config as Record<string, unknown>) };
121
157
  for (const f of ["requireEmailVerification", "requireSignupApproval", "allowDelete", "hibp", "auditLog"] as const) {
122
158
  if (u[f] !== undefined) config[f] = u[f];
123
159
  }
160
+ if (u.sessionIdleTimeoutMinutes !== undefined) {
161
+ config.sessionIdleTimeoutMinutes = u.sessionIdleTimeoutMinutes;
162
+ }
124
163
  row.config = config;
125
164
  await this.repo.save(row);
126
165
  }
166
+
167
+ private async resetSessionExpirations(minutes: number | null): Promise<void> {
168
+ const lifetimeSeconds = minutes === null
169
+ ? DEFAULT_SESSION_LIFETIME_SECONDS
170
+ : minutes * 60;
171
+ await this.repo.manager.query(
172
+ `UPDATE "session"
173
+ SET "expiresAt" = CURRENT_TIMESTAMP + ($1 * INTERVAL '1 second'),
174
+ "updatedAt" = CURRENT_TIMESTAMP
175
+ WHERE "expiresAt" > CURRENT_TIMESTAMP`,
176
+ [lifetimeSeconds],
177
+ );
178
+ }
127
179
  }
@@ -7,8 +7,8 @@
7
7
  ".claude/skills/podokit-configure-auth/**"
8
8
  ],
9
9
  "dependencies": {
10
- "@podosoft/podokit-auth": "^0.2.0",
11
- "@podosoft/podokit-contracts": "^0.2.0",
10
+ "@podosoft/podokit-auth": "^0.3.0",
11
+ "@podosoft/podokit-contracts": "^0.3.0",
12
12
  "better-auth": "^1.6.23",
13
13
  "@thallesp/nestjs-better-auth": "^2.7.0",
14
14
  "pg": "^8.12.0",
@@ -44,6 +44,7 @@
44
44
  "# AUTH_EMAIL_VERIFICATION=false",
45
45
  "# AUTH_REQUIRE_SIGNUP_APPROVAL=false",
46
46
  "# AUTH_ALLOW_DELETE=false",
47
+ "# AUTH_SESSION_IDLE_TIMEOUT_MINUTES=30",
47
48
  "# GOOGLE_CLIENT_ID=",
48
49
  "# GOOGLE_CLIENT_SECRET=",
49
50
  "# GITHUB_CLIENT_ID=",
@@ -109,7 +110,7 @@
109
110
  "file": "AGENTS.md",
110
111
  "marker": "<!-- podokit:end:agents-modules -->",
111
112
  "optional": true,
112
- "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, sign-up approval, and the other server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Use `npm run auth:configure -w <app>-api -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets. The sign-up approval gate is provider-independent; do not add provider-specific approval checks.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
113
+ "text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and the other server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Use `npm run auth:configure -w <app>-api -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets. The sign-up approval gate is provider-independent; do not add provider-specific approval checks.\n- Development setup: create auth tables with `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`, then run TypeORM migrations. Production images can run both from compiled output with `npm run migrate:all`.\n"
113
114
  }
114
115
  ],
115
116
  "instructions": [
@@ -120,6 +121,6 @@
120
121
  "Sign up: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-up/email -H 'content-type: application/json' -d '{\"email\":\"a@example.com\",\"password\":\"password123\",\"name\":\"A\"}'",
121
122
  "Call a protected route with the session: curl -b cookies.txt localhost:5002/account/me",
122
123
  "Use npm run auth:configure -w <app>-api -- --help for redacted OAuth, SMTP, and sign-up approval automation.",
123
- "Everything is protected by default; mark public routes with @Public(). Configure OAuth providers, SMTP, and server-enforced toggles on the admin Settings page \u2014 stored encrypted in the DB and applied live (no restart). Environment variables (GOOGLE_CLIENT_*, SMTP_*, AUTH_*) still work as an optional fallback/override."
124
+ "Everything is protected by default; mark public routes with @Public(). Configure OAuth providers, SMTP, automatic logout, and server-enforced toggles on the admin Settings page \u2014 stored in the DB and applied live (no restart). Environment variables (GOOGLE_CLIENT_*, SMTP_*, AUTH_*) still work as an optional fallback/override."
124
125
  ]
125
126
  }
@@ -23,7 +23,7 @@ test("file upload: multipart upload returns a key and url @smoke", async ({ play
23
23
  await ctx.dispose();
24
24
  });
25
25
 
26
- test("file upload: a request with no file is a 400 @smoke", async ({ playwright }) => {
26
+ test("file upload: a request with no file is a 400", async ({ playwright }) => {
27
27
  const ctx = await session(playwright);
28
28
  const res = await ctx.post("/api/files", { multipart: { notfile: "x" } });
29
29
  expect(res.status()).toBe(400);
@@ -5,7 +5,7 @@
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "nodemailer": "^7.0.9",
8
- "@podosoft/podokit-auth": "^0.2.0",
8
+ "@podosoft/podokit-auth": "^0.3.0",
9
9
  "pg": "^8.12.0"
10
10
  },
11
11
  "devDependencies": {
@@ -10,7 +10,7 @@ async function session(playwright: import("@playwright/test").PlaywrightWorkerAr
10
10
  return ctx;
11
11
  }
12
12
 
13
- test("sse: publish accepts a message @smoke", async ({ playwright }) => {
13
+ test("sse: publish accepts a message", async ({ playwright }) => {
14
14
  const ctx = await session(playwright);
15
15
  const pub = await ctx.post("/api/events", { data: { message: `hi-${Date.now()}` } });
16
16
  expect(pub.ok()).toBeTruthy();
@@ -14,7 +14,7 @@
14
14
  "migration:generate": "npm run typeorm -- migration:generate"
15
15
  },
16
16
  "dependencies": {
17
- "@podosoft/podokit-contracts": "^0.2.0",
17
+ "@podosoft/podokit-contracts": "^0.3.0",
18
18
  "@nestjs/common": "^11.1.27",
19
19
  "@nestjs/config": "^4.0.4",
20
20
  "@nestjs/core": "^11.1.27",
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
- "@podosoft/podokit-api-client": "^0.6.0",
16
+ "@podosoft/podokit-api-client": "^0.7.0",
17
17
  "bits-ui": "^2.18.1",
18
18
  "clsx": "^2.1.1",
19
19
  "mode-watcher": "^1.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.12.3",
3
+ "version": "0.13.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": {