@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.
- package/dist/deploy-compose-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +20 -3
- package/dist/templates/fullstack/AGENTS.md +12 -0
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/todo/README.md +1 -1
- 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)
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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(
|
|
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
|
|
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
|
-
{
|
|
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 =
|
|
79
|
-
f.prefixes.some((p) => path === p || path.startsWith(p)),
|
|
80
|
-
);
|
|
109
|
+
const feature = featureForPath(path);
|
|
81
110
|
if (!feature) return;
|
|
82
|
-
|
|
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.
|
|
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": {
|