@podosoft/podokit 1.4.0 → 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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/deploy-compose-render.js +2 -1
  3. package/dist/deploy-render.js +14 -8
  4. package/dist/lockfile.d.ts +8 -1
  5. package/dist/lockfile.js +22 -3
  6. package/dist/templates/fullstack/AGENTS.md +22 -3
  7. package/dist/templates/fullstack/README.md +10 -7
  8. package/dist/templates/fullstack/apps/api/package.json +1 -1
  9. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
  10. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
  11. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
  12. package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
  13. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
  14. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
  15. package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
  16. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  17. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  18. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  19. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
  20. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
  21. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  22. package/dist/templates/fullstack/dot-env.docker.example +4 -0
  23. package/dist/templates/fullstack/dot-env.example +7 -0
  24. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  25. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  34. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  35. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  46. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  47. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  50. package/dist/templates/modules/auth/module.manifest.json +0 -1
  51. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
  52. package/dist/templates/modules/file-upload/module.manifest.json +2 -2
  53. package/dist/templates/todo/AGENTS.md +10 -3
  54. package/dist/templates/todo/README.md +10 -8
  55. package/package.json +1 -1
  56. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
@@ -1,5 +1,6 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
+ import { exclusive } from "../helpers/exclusive";
3
4
 
4
5
  // admin storageState (project default)
5
6
 
@@ -30,16 +31,19 @@ test("general settings: shows the favicon uploader", async ({ page }) => {
30
31
  });
31
32
 
32
33
  test("general settings: edit the site name, save, and apply live", async ({ page }) => {
33
- await ready(page, "/admin/settings");
34
- const name = page.getByLabel("Site name");
35
- const original = await name.inputValue();
36
- await name.fill("PodoKit Test Site");
37
- await saveGeneral(page);
38
- // the browser tab title reflects the new name live (no reload)
39
- await expect(page).toHaveTitle("PodoKit Test Site");
40
- // restore so other specs start clean
41
- await name.fill(original);
42
- await saveGeneral(page);
34
+ test.slow();
35
+ await exclusive("site-general", async () => {
36
+ await ready(page, "/admin/settings");
37
+ const name = page.getByLabel("Site name");
38
+ const original = await name.inputValue();
39
+ await name.fill("PodoKit Test Site");
40
+ await saveGeneral(page);
41
+ // the browser tab title reflects the new name live (no reload)
42
+ await expect(page).toHaveTitle("PodoKit Test Site");
43
+ // restore so other specs start clean
44
+ await name.fill(original);
45
+ await saveGeneral(page);
46
+ });
43
47
  });
44
48
 
45
49
  test("settings lists the auth features under the Authentication tab", async ({ page }) => {
@@ -69,18 +73,21 @@ test("settings is reachable from the sidebar", async ({ page }) => {
69
73
  });
70
74
 
71
75
  test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
72
- await ready(page, "/admin/settings");
73
- await page.getByRole("tab", { name: "Authentication" }).click();
74
- const toggle = page.getByRole("switch", { name: "Magic link" });
75
- test.skip((await toggle.count()) === 0, "magic link toggle not present");
76
- // Serial workers (workers:1) make toggle-then-restore safe for other specs.
77
- await expect(toggle).toBeChecked();
78
- await toggle.click();
79
- await expect(page.getByText("Setting updated")).toBeVisible();
80
- await expect(toggle).not.toBeChecked(); // capabilities refreshed live
81
- // restore so the shared magic-link/2FA specs keep their expected state
82
- await toggle.click();
83
- await expect(toggle).toBeChecked();
76
+ test.slow();
77
+ await exclusive("magic-link", async () => {
78
+ await ready(page, "/admin/settings");
79
+ await page.getByRole("tab", { name: "Authentication" }).click();
80
+ const toggle = page.getByRole("switch", { name: "Magic link" });
81
+ test.skip((await toggle.count()) === 0, "magic link toggle not present");
82
+ // The magic-link lock keeps the API spec that toggles the same flag out.
83
+ await expect(toggle).toBeChecked();
84
+ await toggle.click();
85
+ await expect(page.getByText("Setting updated")).toBeVisible();
86
+ await expect(toggle).not.toBeChecked(); // capabilities refreshed live
87
+ // restore so the shared magic-link/2FA specs keep their expected state
88
+ await toggle.click();
89
+ await expect(toggle).toBeChecked();
90
+ });
84
91
  });
85
92
 
86
93
  test("settings: the require-two-factor toggle is available", async ({ page }) => {
@@ -95,68 +102,71 @@ test("settings: the require-two-factor toggle is available", async ({ page }) =>
95
102
  });
96
103
 
97
104
  test("inactive browser sessions are signed out automatically @smoke", async ({ browser, page: adminPage }) => {
98
- // Idle sign-out revokes the active server session. Use a dedicated login so
99
- // this test cannot invalidate the shared admin storageState used by later specs.
100
- const context = await browser.newContext({ baseURL: base });
101
- const page = await context.newPage();
102
- const email = `idle-admin-${Date.now()}@example.com`;
103
- let signOutRequests = 0;
104
- let userId = "";
105
- try {
106
- const created = await adminPage.request.post("/api/auth/admin/create-user", {
107
- headers: { origin: base },
108
- data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
109
- });
110
- expect(created.ok()).toBeTruthy();
111
- userId = ((await created.json()).user?.id ?? "") as string;
112
- expect(userId).toBeTruthy();
113
-
114
- const signIn = await context.request.post("/api/auth/sign-in/email", {
115
- headers: { origin: base },
116
- data: { email, password: "Podokit3e-Str0ng!pw" },
117
- });
118
- expect(signIn.ok()).toBeTruthy();
119
-
120
- const configured = await context.request.put("/api/account/auth-config", {
121
- data: { server: { sessionIdleTimeoutMinutes: 5 } },
122
- });
123
- expect(configured.ok()).toBeTruthy();
124
- await page.route("**/api/auth/sign-out", async (route) => {
125
- signOutRequests += 1;
126
- await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
127
- });
128
- await page.clock.install();
129
-
105
+ test.slow();
106
+ await exclusive("session-idle-timeout", async () => {
107
+ // Idle sign-out revokes the active server session. Use a dedicated login so
108
+ // this test cannot invalidate the shared admin storageState used by later specs.
109
+ const context = await browser.newContext({ baseURL: base });
110
+ const page = await context.newPage();
111
+ const email = `idle-admin-${Date.now()}@example.com`;
112
+ let signOutRequests = 0;
113
+ let userId = "";
130
114
  try {
131
- await ready(page, "/admin");
132
- await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
133
- } finally {
134
- // Keep the loaded page policy at five minutes while restoring the shared
135
- // DB policy before the isolated session is revoked.
136
- const restored = await context.request.put("/api/account/auth-config", {
137
- data: { server: { sessionIdleTimeoutMinutes: null } },
115
+ const created = await adminPage.request.post("/api/auth/admin/create-user", {
116
+ headers: { origin: base },
117
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
138
118
  });
139
- expect(restored.ok()).toBeTruthy();
140
- }
119
+ expect(created.ok()).toBeTruthy();
120
+ userId = ((await created.json()).user?.id ?? "") as string;
121
+ expect(userId).toBeTruthy();
141
122
 
142
- const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
143
- headers: { origin: base },
144
- data: { userId },
145
- });
146
- expect(removed.ok()).toBeTruthy();
147
- userId = "";
148
-
149
- await page.clock.fastForward(5 * 60_000 + 1_000);
150
- await expect.poll(() => signOutRequests).toBe(1);
151
- await expect(page).toHaveURL(/\/login\?reason=idle$/);
152
- await expect(page.getByTestId("session-timeout-message")).toBeVisible();
153
- } finally {
154
- if (userId) {
155
- await adminPage.request.post("/api/auth/admin/remove-user", {
123
+ const signIn = await context.request.post("/api/auth/sign-in/email", {
124
+ headers: { origin: base },
125
+ data: { email, password: "Podokit3e-Str0ng!pw" },
126
+ });
127
+ expect(signIn.ok()).toBeTruthy();
128
+
129
+ const configured = await context.request.put("/api/account/auth-config", {
130
+ data: { server: { sessionIdleTimeoutMinutes: 5 } },
131
+ });
132
+ expect(configured.ok()).toBeTruthy();
133
+ await page.route("**/api/auth/sign-out", async (route) => {
134
+ signOutRequests += 1;
135
+ await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
136
+ });
137
+ await page.clock.install();
138
+
139
+ try {
140
+ await ready(page, "/admin");
141
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
142
+ } finally {
143
+ // Keep the loaded page policy at five minutes while restoring the shared
144
+ // DB policy before the isolated session is revoked.
145
+ const restored = await context.request.put("/api/account/auth-config", {
146
+ data: { server: { sessionIdleTimeoutMinutes: null } },
147
+ });
148
+ expect(restored.ok()).toBeTruthy();
149
+ }
150
+
151
+ const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
156
152
  headers: { origin: base },
157
153
  data: { userId },
158
154
  });
155
+ expect(removed.ok()).toBeTruthy();
156
+ userId = "";
157
+
158
+ await page.clock.fastForward(5 * 60_000 + 1_000);
159
+ await expect.poll(() => signOutRequests).toBe(1);
160
+ await expect(page).toHaveURL(/\/login\?reason=idle$/);
161
+ await expect(page.getByTestId("session-timeout-message")).toBeVisible();
162
+ } finally {
163
+ if (userId) {
164
+ await adminPage.request.post("/api/auth/admin/remove-user", {
165
+ headers: { origin: base },
166
+ data: { userId },
167
+ });
168
+ }
169
+ await context.close();
159
170
  }
160
- await context.close();
161
- }
171
+ });
162
172
  });
@@ -1,6 +1,7 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ADMIN, USER, anonState } from "../helpers/accounts";
3
3
  import { totpCode } from "../helpers/totp";
4
+ import { exclusive } from "../helpers/exclusive";
4
5
 
5
6
  test.use({ storageState: anonState });
6
7
 
@@ -85,58 +86,61 @@ test("two-factor: sign in with an authenticator code from the login page", async
85
86
  });
86
87
 
87
88
  test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
88
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
89
- const caps = await (await admin.get("/api/account/capabilities")).json();
90
- test.skip(!caps?.twoFactor, "two-factor not enabled");
91
- await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
92
-
93
- const email = `rq-ui-${Date.now()}@example.com`;
94
- const pw = "Podokit3e-Str0ng!pw";
95
- try {
96
- await admin.put("/api/account/settings", { data: { require2fa: true } });
97
- // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
98
- const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
99
- await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
100
- await expect(async () => {
101
- expect((await probe.get("/api/account/me")).status()).toBe(403);
102
- }).toPass({ timeout: 8000 });
103
- await probe.dispose();
104
-
105
- // Signing in toward a protected route lands on the mandatory enrolment page.
106
- await openHydratedLogin(page, "/account");
107
- await page.getByLabel("Email").fill(email);
108
- await page.getByLabel("Password").fill(pw);
109
- await page.getByRole("button", { name: "Sign in", exact: true }).click();
110
- await expect(page).toHaveURL(/\/setup-2fa/);
111
-
112
- // Complete enrolment entirely through the UI: password → QR + backup codes →
113
- // authenticator code → activate. The code is computed from the on-page URI.
114
- await page.getByLabel("Confirm your password").fill(pw);
115
- await page.getByRole("button", { name: "Continue" }).click();
116
- await expect(page.getByTestId("backup-codes")).toBeVisible();
117
- await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
118
- const uri = (await page.locator("code").first().innerText()).trim();
119
- await page.getByRole("checkbox").click();
120
- await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
121
- await page.getByRole("button", { name: "Activate and continue" }).click();
122
-
123
- // Enrolled → the gate lets the regular user into their account page.
124
- await expectAccountPage(page);
125
-
126
- // An enrolled user who revisits setup is returned to the same non-admin page.
127
- await page.goto("/setup-2fa");
128
- await expectAccountPage(page);
129
- } finally {
130
- await admin.put("/api/account/settings", { data: { require2fa: false } });
131
- await expect(async () => {
89
+ test.slow();
90
+ await exclusive("require2fa", async () => {
91
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
+ const caps = await (await admin.get("/api/account/capabilities")).json();
93
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
94
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
95
+
96
+ const email = `rq-ui-${Date.now()}@example.com`;
97
+ const pw = "Podokit3e-Str0ng!pw";
98
+ try {
99
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
100
+ // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
132
101
  const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
133
- await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
134
- const status = (await probe.get("/api/account/me")).status();
102
+ await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
103
+ await expect(async () => {
104
+ expect((await probe.get("/api/account/me")).status()).toBe(403);
105
+ }).toPass({ timeout: 8000 });
135
106
  await probe.dispose();
136
- expect(status).toBe(200);
137
- }).toPass({ timeout: 8000 });
138
- await admin.dispose();
139
- }
107
+
108
+ // Signing in toward a protected route lands on the mandatory enrolment page.
109
+ await openHydratedLogin(page, "/account");
110
+ await page.getByLabel("Email").fill(email);
111
+ await page.getByLabel("Password").fill(pw);
112
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
113
+ await expect(page).toHaveURL(/\/setup-2fa/);
114
+
115
+ // Complete enrolment entirely through the UI: password → QR + backup codes →
116
+ // authenticator code → activate. The code is computed from the on-page URI.
117
+ await page.getByLabel("Confirm your password").fill(pw);
118
+ await page.getByRole("button", { name: "Continue" }).click();
119
+ await expect(page.getByTestId("backup-codes")).toBeVisible();
120
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
121
+ const uri = (await page.locator("code").first().innerText()).trim();
122
+ await page.getByRole("checkbox").click();
123
+ await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
124
+ await page.getByRole("button", { name: "Activate and continue" }).click();
125
+
126
+ // Enrolled → the gate lets the regular user into their account page.
127
+ await expectAccountPage(page);
128
+
129
+ // An enrolled user who revisits setup is returned to the same non-admin page.
130
+ await page.goto("/setup-2fa");
131
+ await expectAccountPage(page);
132
+ } finally {
133
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
134
+ await expect(async () => {
135
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
136
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
137
+ const status = (await probe.get("/api/account/me")).status();
138
+ await probe.dispose();
139
+ expect(status).toBe(200);
140
+ }).toPass({ timeout: 8000 });
141
+ await admin.dispose();
142
+ }
143
+ });
140
144
  });
141
145
 
142
146
  test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {
@@ -34,7 +34,7 @@
34
34
  }
35
35
  ],
36
36
  "instructions": [
37
- "Run migrations to create the audit_logs table: {{apiRun}} migration:run{{apiWorkspaceArg}}",
37
+ "Run migrations to create the audit_logs table: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
38
38
  "Auth/admin actions are recorded by a Better Auth hook. Record semantic application events with AuditService.recordRequest() or recordAudit(); use recordAuditLater() only for high-volume, non-security events.",
39
39
  "List entries newest first (admin session required): curl -i -b cookies.txt 'localhost:5002/audit-logs?limit=50' and pass the X-Next-Cursor header back as ?cursor= for the next page"
40
40
  ],
@@ -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
+ }
@@ -19,7 +19,6 @@
19
19
  "@types/pg": "^8.11.10"
20
20
  },
21
21
  "scripts": {
22
- "migrate:all": "bun scripts/run-migrations.mjs",
23
22
  "auth:configure": "bun scripts/configure-auth.mjs"
24
23
  },
25
24
  "env": [
@@ -77,12 +77,18 @@ test("chunked upload: parts, a retried part, resume status, and complete store t
77
77
  // Completing again returns the same result.
78
78
  expect(await (await ctx.post(`${uploads}/${upload.id}/complete`, { data: {} })).json()).toMatchObject({ key: result.key });
79
79
 
80
- // S3 returns an absolute presigned URL; local storage a signed API path.
81
- const download = await ctx.get(result.url.startsWith("/") ? `${apiBase}${result.url}` : result.url);
82
- expect(download.status()).toBe(200);
83
- const stored = await download.body();
84
- expect(stored.byteLength).toBe(file.byteLength);
85
- expect(sha256(stored)).toBe(sha256(file));
80
+ // Local storage returns a signed API path. S3 returns a URL signed for the API's
81
+ // S3 endpoint; a containerized dev stack signs an in-network host, so download it
82
+ // only when the runner was given that endpoint too. The completed size above
83
+ // comes from the stored object either way.
84
+ const local = result.url.startsWith("/");
85
+ if (local || process.env.S3_ENDPOINT) {
86
+ const download = await ctx.get(local ? `${apiBase}${result.url}` : result.url);
87
+ expect(download.status()).toBe(200);
88
+ const stored = await download.body();
89
+ expect(stored.byteLength).toBe(file.byteLength);
90
+ expect(sha256(stored)).toBe(sha256(file));
91
+ }
86
92
  } finally {
87
93
  await ctx.dispose();
88
94
  }
@@ -31,12 +31,12 @@
31
31
  "file": "AGENTS.md",
32
32
  "marker": "<!-- podokit:end:agents-modules -->",
33
33
  "optional": true,
34
- "text": "### file-upload\n- `POST /files` stores one multipart file under `uploads/<userId>/`; `GET /files/:key/url` presigns it for the owner or an administrator.\n- For large files use the resumable upload in the browser: `uploadFile(file, { onProgress, signal })` and `resumeUpload(id, file)` from `$lib/uploads`. They create a session (`POST /files/uploads`), send parts (`PUT /files/uploads/:id/parts/:n`, `application/octet-stream`, `Content-Length` equal to the part size; with `XMLHttpRequest` in the browser so `onProgress` reports bytes), retry failed parts honoring `Retry-After`, and complete (`POST /files/uploads/:id/complete`). `GET /files/uploads/:id` lists stored parts; `DELETE` cancels.\n- Upload sessions live in the database (`file_upload_sessions`, `file_upload_parts`), so any API replica serves any request; never keep upload state in memory. Run `{{apiRun}} migration:run{{apiWorkspaceArg}}` after adding the module.\n- Parts are larger than the web `BODY_SIZE_LIMIT`, so the gateway sends `/api/files/uploads/` straight to the API: `podo add` listed it in `.podokit/dev.json` `apiPaths` and each deployment profile's `exposure.apiPaths`, and `podo deploy init` adds it to new profiles. Keep it there.\n- POST bodies must be `application/json` and parts `application/octet-stream` (else 415 `UNSUPPORTED_MEDIA_TYPE`), and a browser `Origin` outside `CORS_ORIGIN` gets 403. Branch on `error.code`: `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_PART_SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `UPLOAD_COMPLETING`, `UPLOAD_EXPIRED`, `UPLOAD_ABORTED`.\n"
34
+ "text": "### file-upload\n- `POST /files` stores one multipart file under `uploads/<userId>/`; `GET /files/:key/url` presigns it for the owner or an administrator.\n- For large files use the resumable upload in the browser: `uploadFile(file, { onProgress, signal })` and `resumeUpload(id, file)` from `$lib/uploads`. They create a session (`POST /files/uploads`), send parts (`PUT /files/uploads/:id/parts/:n`, `application/octet-stream`, `Content-Length` equal to the part size; with `XMLHttpRequest` in the browser so `onProgress` reports bytes), retry failed parts honoring `Retry-After`, and complete (`POST /files/uploads/:id/complete`). `GET /files/uploads/:id` lists stored parts; `DELETE` cancels.\n- Upload sessions live in the database (`file_upload_sessions`, `file_upload_parts`), so any API replica serves any request; never keep upload state in memory. Run `{{apiRun}} migrate:all{{apiWorkspaceArg}}` after adding the module.\n- Parts are larger than the web `BODY_SIZE_LIMIT`, so the gateway sends `/api/files/uploads/` straight to the API: `podo add` listed it in `.podokit/dev.json` `apiPaths` and each deployment profile's `exposure.apiPaths`, and `podo deploy init` adds it to new profiles. Keep it there.\n- POST bodies must be `application/json` and parts `application/octet-stream` (else 415 `UNSUPPORTED_MEDIA_TYPE`), and a browser `Origin` outside `CORS_ORIGIN` gets 403. Branch on `error.code`: `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_PART_SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `UPLOAD_COMPLETING`, `UPLOAD_EXPIRED`, `UPLOAD_ABORTED`.\n"
35
35
  }
36
36
  ],
37
37
  "instructions": [
38
38
  "Configure the selected object-storage provider before uploading files.",
39
- "Run migrations to create the upload session tables: {{apiRun}} migration:run{{apiWorkspaceArg}}",
39
+ "Run migrations to create the upload session tables: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
40
40
  "Uploads are stored as uploads/<userId>/<uuid>-<name>. Add the auth module so every upload has an owner.",
41
41
  "Upload a file: curl -b cookies.txt -F 'file=@./some.png' localhost:5002/files",
42
42
  "The response includes { key, url } — an S3 presigned URL or a signed local download URL.",