@podosoft/podokit 1.4.1 → 1.4.2

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 (44) hide show
  1. package/dist/deploy-compose-render.js +2 -1
  2. package/dist/deploy-render.js +14 -8
  3. package/dist/lockfile.d.ts +8 -1
  4. package/dist/lockfile.js +20 -3
  5. package/dist/templates/fullstack/AGENTS.md +12 -0
  6. package/dist/templates/fullstack/README.md +1 -1
  7. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  8. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  11. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  12. package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
  13. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  14. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
  17. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  18. package/dist/templates/fullstack/dot-env.docker.example +4 -0
  19. package/dist/templates/fullstack/dot-env.example +7 -0
  20. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  21. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  25. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  26. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  27. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  28. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  29. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
  31. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  32. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  33. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  34. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  40. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  43. package/dist/templates/todo/README.md +1 -1
  44. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
3
3
  import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer, openAPI } from "better-auth/plugins";
4
4
  import { actionEmail, sendMail } from "../mail/mailer";
5
5
  import { sendSms } from "../sms/sms";
6
- import { assertUserCreationAllowed, createFeatureGate, readSignupOpen } from "./feature-gate";
6
+ import { assertUserCreationAllowed, createFeatureGate, featureGatePlugin, readSignupOpen, type FeatureGateSettings } from "./feature-gate";
7
7
  import { featureFlags, isSignupOpen } from "./auth-config-store";
8
8
  import { orgAc, orgRoles } from "./org-permissions";
9
9
  import { authDatabase } from "./db";
@@ -48,7 +48,13 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
48
48
  // server-enforced toggles) apply without a restart. Everything below the injection
49
49
  // markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
50
50
  export function buildAuth(config: AuthConfig) {
51
+ // The feature gate reads the process-wide settings snapshot, so a rebuild does
52
+ // not start a new cache.
53
+ const gateSettings: FeatureGateSettings = { flags: featureFlags, signupOpen: isSignupOpen };
51
54
  const plugins: BetterAuthPlugin[] = [
55
+ // First, so a disabled feature answers 404 before another plugin's onRequest
56
+ // (such as the OIDC provider's discovery metadata) can answer.
57
+ featureGatePlugin(gateSettings),
52
58
  twoFactor(),
53
59
  magicLink({
54
60
  sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
@@ -179,11 +185,11 @@ export function buildAuth(config: AuthConfig) {
179
185
  };
180
186
 
181
187
  // Request-time hooks. The feature gate turns the admin Settings toggles into a
182
- // real server boundary (disabled features 404). It reads the process-wide
183
- // settings snapshot, so a rebuild does not start a new cache. Other PodoKit
184
- // modules add their own hooks below the marker.
188
+ // real server boundary (disabled features 404) for endpoint calls, including
189
+ // in-process auth.api calls the plugin above never sees. Other PodoKit modules
190
+ // add their own hooks below the marker.
185
191
  const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
186
- before: createFeatureGate({ flags: featureFlags, signupOpen: isSignupOpen }),
192
+ before: createFeatureGate(gateSettings),
187
193
  };
188
194
  // podokit:begin:auth-hooks
189
195
  // podokit:end:auth-hooks
@@ -1,7 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { oauthProvider } from "@better-auth/oauth-provider";
3
+ import { betterAuth } from "better-auth";
4
+ import { memoryAdapter } from "better-auth/adapters/memory";
5
+ import { jwt } from "better-auth/plugins";
2
6
  import { FLAG_DEFAULTS, flagsFrom } from "../settings/flag-defaults";
3
7
  import {
8
+ authRequestPath,
4
9
  enforceFeatureGate,
10
+ featureForPath,
11
+ featureGatePlugin,
5
12
  PUBLIC_SIGNUP_DISABLED,
6
13
  signupOpenFrom,
7
14
  type FeatureFlags,
@@ -56,3 +63,79 @@ describe("feature gate", () => {
56
63
  expect(signupOpenFrom(new Map())).toBe(true);
57
64
  });
58
65
  });
66
+
67
+ describe("OIDC provider gate", () => {
68
+ test("covers the provider's endpoints but not the jwt plugin's", () => {
69
+ for (const path of [
70
+ "/oauth2/authorize",
71
+ "/oauth2/token",
72
+ "/oauth2/userinfo",
73
+ "/oauth2/register",
74
+ "/oauth2/consent",
75
+ "/oauth2/create-client",
76
+ "/oauth2/end-session",
77
+ "/admin/oauth2/create-client",
78
+ "/.well-known/openid-configuration",
79
+ "/.well-known/oauth-authorization-server",
80
+ "/.well-known/oauth-authorization-server/api/auth",
81
+ ]) {
82
+ expect(featureForPath(path)).toBe("oidcProvider");
83
+ }
84
+ for (const path of ["/jwks", "/token", "/get-session", "/sign-in/email"]) {
85
+ expect(featureForPath(path)).toBeUndefined();
86
+ }
87
+ });
88
+
89
+ test("resolves request paths against the auth base URL", () => {
90
+ const base = "http://localhost:5002/api/auth";
91
+ expect(authRequestPath("http://localhost:5002/api/auth/oauth2/authorize?client_id=x", base))
92
+ .toBe("/oauth2/authorize");
93
+ expect(authRequestPath("http://localhost:5002/api/auth/.well-known/openid-configuration", `${base}/`))
94
+ .toBe("/.well-known/openid-configuration");
95
+ expect(authRequestPath("http://localhost:5002/.well-known/oauth-authorization-server/api/auth", base))
96
+ .toBe("/.well-known/oauth-authorization-server/api/auth");
97
+ expect(authRequestPath("http://localhost:5002/api/authority/oauth2/token", base))
98
+ .toBe("/api/authority/oauth2/token");
99
+ });
100
+
101
+ test("answers the provider's discovery with 404 while it is disabled and serves it once enabled", async () => {
102
+ let enabled = false;
103
+ const auth = betterAuth({
104
+ database: memoryAdapter({ jwks: [] }),
105
+ baseURL: "http://localhost:5002",
106
+ secret: "feature-gate-spec-secret-0123456789abcdef",
107
+ logger: { disabled: true },
108
+ plugins: [
109
+ featureGatePlugin({
110
+ flags: async () => ({ ...FLAG_DEFAULTS, oidcProvider: enabled }),
111
+ signupOpen: async () => true,
112
+ }),
113
+ jwt(),
114
+ // Serves the discovery metadata from its own onRequest hook.
115
+ oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
116
+ ],
117
+ });
118
+ const request = (path: string): Promise<Response> =>
119
+ auth.handler(new Request(`http://localhost:5002${path}`));
120
+ const discovery = [
121
+ "/api/auth/.well-known/openid-configuration",
122
+ "/api/auth/.well-known/oauth-authorization-server",
123
+ "/.well-known/oauth-authorization-server/api/auth",
124
+ ];
125
+
126
+ for (const path of discovery) {
127
+ const response = await request(path);
128
+ expect(response.status).toBe(404);
129
+ expect(await response.json()).toEqual({ message: "This feature is disabled." });
130
+ }
131
+ expect((await request("/api/auth/oauth2/authorize?client_id=probe")).status).toBe(404);
132
+ expect((await request("/api/auth/jwks")).status).toBe(200);
133
+
134
+ enabled = true;
135
+ for (const path of discovery) {
136
+ const response = await request(path);
137
+ expect(response.status).toBe(200);
138
+ expect((await response.json()).issuer).toBe("http://localhost:5002/api/auth");
139
+ }
140
+ });
141
+ });
@@ -1,10 +1,13 @@
1
+ import type { BetterAuthPlugin } from "better-auth";
1
2
  import { APIError, createAuthMiddleware } from "better-auth/api";
2
3
  import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
3
4
 
4
5
  export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
5
6
 
6
7
  /** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
7
- * gated when it equals a prefix or starts with a prefix ending in "/". */
8
+ * gated when it starts with one of its feature's prefixes. The OIDC provider's
9
+ * JWKS (`/jwks`) and session JWT (`/token`) belong to the always-on `jwt`
10
+ * plugin, so they stay available while the provider is off. */
8
11
  const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
9
12
  { flag: "twoFactor", prefixes: ["/two-factor/"] },
10
13
  { flag: "magicLink", prefixes: ["/sign-in/magic-link", "/magic-link/"] },
@@ -15,9 +18,37 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
15
18
  { flag: "apiKey", prefixes: ["/api-key/"] },
16
19
  { flag: "passkey", prefixes: ["/passkey/", "/sign-in/passkey"] },
17
20
  { flag: "organization", prefixes: ["/organization"] },
18
- { flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
21
+ {
22
+ flag: "oidcProvider",
23
+ prefixes: [
24
+ "/oauth2/",
25
+ "/admin/oauth2/",
26
+ "/.well-known/openid-configuration",
27
+ // Also matches the RFC 8414 form outside the base path,
28
+ // /.well-known/oauth-authorization-server/api/auth.
29
+ "/.well-known/oauth-authorization-server",
30
+ "/.well-known/oauth-protected-resource",
31
+ ],
32
+ },
19
33
  ];
20
34
 
35
+ /** The admin-toggleable feature that owns an auth-relative path, if any. */
36
+ export function featureForPath(path: string): FeatureFlag | undefined {
37
+ return FEATURE_PATHS.find((f) => f.prefixes.some((p) => path.startsWith(p)))?.flag;
38
+ }
39
+
40
+ /** A request's path relative to Better Auth's base URL. A path outside the base
41
+ * path is returned whole. */
42
+ export function authRequestPath(requestUrl: string, baseURL: string): string {
43
+ const pathname = new URL(requestUrl).pathname;
44
+ const basePath = new URL(baseURL).pathname.replace(/\/+$/, "");
45
+ return basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
46
+ }
47
+
48
+ function featureDisabled(): APIError {
49
+ return new APIError("NOT_FOUND", { message: "This feature is disabled." });
50
+ }
51
+
21
52
  export type FeatureFlags = Record<FeatureFlag, boolean>;
22
53
 
23
54
  /** Live settings the gate reads per request, normally the shared settings snapshot. */
@@ -75,12 +106,9 @@ export async function enforceFeatureGate(path: string, settings: FeatureGateSett
75
106
  assertUserCreationAllowed(await readSignupOpen(settings.signupOpen), path);
76
107
  return;
77
108
  }
78
- const feature = FEATURE_PATHS.find((f) =>
79
- f.prefixes.some((p) => path === p || path.startsWith(p)),
80
- );
109
+ const feature = featureForPath(path);
81
110
  if (!feature) return;
82
- const enabled = (await readFeatureFlags(settings.flags))[feature.flag];
83
- if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
111
+ if (!(await readFeatureFlags(settings.flags))[feature]) throw featureDisabled();
84
112
  }
85
113
 
86
114
  /** Better Auth middleware over {@link enforceFeatureGate}. The settings come from
@@ -91,3 +119,19 @@ export function createFeatureGate(settings: FeatureGateSettings): ReturnType<typ
91
119
  await enforceFeatureGate(ctx.path, settings);
92
120
  });
93
121
  }
122
+
123
+ /** Rejects HTTP requests to a disabled feature before any plugin's `onRequest`
124
+ * runs. `hooks.before` alone is not enough: the OAuth provider answers its
125
+ * discovery metadata from `onRequest`, which Better Auth runs before endpoint
126
+ * hooks. Register this plugin ahead of every other plugin. */
127
+ export function featureGatePlugin(settings: FeatureGateSettings): BetterAuthPlugin {
128
+ return {
129
+ id: "podokit-feature-gate",
130
+ onRequest: async (request, ctx) => {
131
+ const feature = featureForPath(authRequestPath(request.url, ctx.baseURL));
132
+ if (!feature || (await readFeatureFlags(settings.flags))[feature]) return undefined;
133
+ const error = featureDisabled();
134
+ return { response: Response.json(error.body, { status: error.statusCode }) };
135
+ },
136
+ };
137
+ }
@@ -131,7 +131,7 @@ Run each service in a separate terminal from the project root:
131
131
  ```
132
132
 
133
133
  - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
134
- - Web: http://localhost:5001
134
+ - Web: http://localhost:5001 — its own health at `/healthz`, which never calls the API
135
135
 
136
136
  For multi-project routing, container profiles, and OAuth over a stable HTTPS
137
137
  tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {