@podosoft/podokit 1.4.2 → 1.6.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.
- package/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -17
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +28 -20
- package/dist/deploy-schema.d.ts +61 -0
- package/dist/deploy-schema.js +157 -1
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +77 -5
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +12 -4
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +44 -7
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +7 -2
- package/dist/templates/fullstack/dot-env.example +21 -1
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +46 -6
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
|
+
import { ADMIN, USER } from "../helpers/accounts";
|
|
2
3
|
import { ready } from "../helpers/hydration";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -34,13 +35,13 @@ test("admin sees the user list and can search", async ({ page }) => {
|
|
|
34
35
|
await ready(page, "/admin/users");
|
|
35
36
|
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
|
|
36
37
|
// Search by exact address — robust to pagination as the user count grows.
|
|
37
|
-
await page.locator("#toolbar-search").fill(
|
|
38
|
+
await page.locator("#toolbar-search").fill(ADMIN.email);
|
|
38
39
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
39
|
-
await expect(page.getByRole("cell", { name:
|
|
40
|
-
await page.locator("#toolbar-search").fill(
|
|
40
|
+
await expect(page.getByRole("cell", { name: ADMIN.email })).toBeVisible();
|
|
41
|
+
await page.locator("#toolbar-search").fill(USER.email);
|
|
41
42
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
42
|
-
await expect(page.getByRole("cell", { name:
|
|
43
|
-
await expect(page.getByRole("cell", { name:
|
|
43
|
+
await expect(page.getByRole("cell", { name: USER.email })).toBeVisible();
|
|
44
|
+
await expect(page.getByRole("cell", { name: ADMIN.email })).toHaveCount(0);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
test("the user list asks the server for one page at a time", async ({ page }) => {
|
|
@@ -63,7 +64,7 @@ test("the user list asks the server for one page at a time", async ({ page }) =>
|
|
|
63
64
|
expect(query.get("search")).toBe("Admin@Example.com");
|
|
64
65
|
expect(query.get("searchField")).toBe("email");
|
|
65
66
|
expect(query.get("offset")).toBe("0");
|
|
66
|
-
await expect(page.getByRole("cell", { name:
|
|
67
|
+
await expect(page.getByRole("cell", { name: ADMIN.email })).toBeVisible();
|
|
67
68
|
expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
|
|
68
69
|
});
|
|
69
70
|
|
|
@@ -78,12 +79,12 @@ test("row menu exposes admin actions", async ({ page }) => {
|
|
|
78
79
|
const searched = page.waitForResponse((response) => {
|
|
79
80
|
const url = new URL(response.url());
|
|
80
81
|
return url.pathname === "/api/admin/users" &&
|
|
81
|
-
url.searchParams.get("search") ===
|
|
82
|
+
url.searchParams.get("search") === USER.email;
|
|
82
83
|
});
|
|
83
|
-
await page.locator("#toolbar-search").fill(
|
|
84
|
+
await page.locator("#toolbar-search").fill(USER.email);
|
|
84
85
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
85
86
|
expect((await searched).ok()).toBeTruthy();
|
|
86
|
-
const row = page.getByRole("row", { name:
|
|
87
|
+
const row = page.getByRole("row", { name: USER.email });
|
|
87
88
|
await row.getByRole("button").click();
|
|
88
89
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
89
90
|
await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
"apps/web/src/lib/i18n/locales/**",
|
|
16
16
|
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
17
17
|
"apps/web/src/lib/server/public-paths.ts",
|
|
18
|
-
"apps/web/src/lib/server/asset-paths.ts"
|
|
18
|
+
"apps/web/src/lib/server/asset-paths.ts",
|
|
19
|
+
"tests/helpers/app-accounts.ts",
|
|
20
|
+
"apps/web/src/lib/server/app-hooks.ts",
|
|
21
|
+
"apps/web/src/lib/server/non-indexable-paths.ts",
|
|
22
|
+
"apps/web/src/lib/admin/app-nav.ts",
|
|
23
|
+
"apps/web/src/lib/admin/app-flags.ts"
|
|
19
24
|
],
|
|
20
25
|
"managedOverrides": [
|
|
21
26
|
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
@@ -119,7 +124,7 @@
|
|
|
119
124
|
"file": "AGENTS.md",
|
|
120
125
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
121
126
|
"optional": true,
|
|
122
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n-
|
|
127
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- For the signed-in user block in a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />`; `sidebar-user-menu.svelte` renders the same menu there and the sidebar menu item inside a `Sidebar.Provider`.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` is managed: put request handling of your own (adjusting `event.locals`, extra headers) in `appHandle` of the app-owned `apps/web/src/lib/server/app-hooks.ts`, which runs after the runtime context is loaded. Add admin sidebar entries to `APP_ADMIN_NAV` in the app-owned `apps/web/src/lib/admin/app-nav.ts`, Settings switches for the API's `APP_FLAG_DEFAULTS` to `APP_FLAG_SETTINGS` in the app-owned `apps/web/src/lib/admin/app-flags.ts`, and private page prefixes that must send `X-Robots-Tag: noindex` to `APP_NON_INDEXABLE_PATHS` in the app-owned `apps/web/src/lib/server/non-indexable-paths.ts`; never edit `admin-sidebar.svelte` or `search-indexing.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- `routes/account`, `routes/setup-2fa`, `routes/(auth)/login`, `routes/(auth)/signup`, and the `routes/(admin)` pages are owned, but their `+page.server.ts` (and the `(admin)` layout load) stay managed. Keep the owned page passing `data` to the managed component (for example `<AccountPage {data} />`) and keep its own chrome; never move a managed load's fields into the owned page or edit the managed load.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
123
128
|
},
|
|
124
129
|
{
|
|
125
130
|
"file": "apps/api/src/app.ts",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAuthMiddleware } from "better-auth/api";
|
|
2
|
+
import { clientAddressOf } from "../core/client-address";
|
|
2
3
|
import { recordAudit } from "./audit-events";
|
|
3
4
|
import { auditEnabled } from "./audit-enabled";
|
|
4
5
|
|
|
@@ -71,6 +72,6 @@ export const auditAfterHook = createAuthMiddleware(async (ctx) => {
|
|
|
71
72
|
actorEmail: actor?.email ?? null,
|
|
72
73
|
targetId,
|
|
73
74
|
targetLabel,
|
|
74
|
-
ip: ctx.headers
|
|
75
|
+
ip: clientAddressOf(ctx.headers) ?? null,
|
|
75
76
|
});
|
|
76
77
|
});
|
|
@@ -29,7 +29,11 @@ export const auditModule: PodokitModule = {
|
|
|
29
29
|
retention: { days: parseRetentionDays(process.env.AUDIT_LOG_RETENTION_DAYS) },
|
|
30
30
|
});
|
|
31
31
|
services.register(AUDIT, audit, () => audit.close());
|
|
32
|
-
services.onStart(() =>
|
|
32
|
+
services.onStart(() => {
|
|
33
|
+
audit.connect();
|
|
34
|
+
// Not awaited: the toggle read may wait for the database, which startup does not.
|
|
35
|
+
void audit.warnIfDisabled();
|
|
36
|
+
});
|
|
33
37
|
},
|
|
34
38
|
plugin: auditPlugin,
|
|
35
39
|
};
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
import { SQL } from "bun";
|
|
3
3
|
import type { QueryRunner } from "typeorm";
|
|
4
4
|
import type { AuthSession } from "../auth/auth.service";
|
|
5
|
+
import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
|
|
5
6
|
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
6
7
|
import { recordAudit, recordAuditLater } from "./audit-events";
|
|
7
8
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
@@ -176,6 +177,38 @@ describe("AuditService", () => {
|
|
|
176
177
|
}
|
|
177
178
|
});
|
|
178
179
|
|
|
180
|
+
test("records the address the API resolved, not the caller's forwarded header", async () => {
|
|
181
|
+
const capture = sqlCapture();
|
|
182
|
+
const service = new AuditService(capture.sql, { dialect: "postgres", enabled: on });
|
|
183
|
+
const session: AuthSession = { user: { id: "user-1" }, session: {} };
|
|
184
|
+
const headers = {
|
|
185
|
+
"x-forwarded-for": "198.51.100.66, 198.51.100.67",
|
|
186
|
+
[CLIENT_ADDRESS_HEADER]: "203.0.113.7",
|
|
187
|
+
};
|
|
188
|
+
await service.recordRequest("todo.create", new Request("http://localhost/todos", { headers }), session);
|
|
189
|
+
await service.recordRequest("todo.delete", new Request("http://localhost/todos", {
|
|
190
|
+
headers: { "x-forwarded-for": "198.51.100.66" },
|
|
191
|
+
}), session);
|
|
192
|
+
expect(capture.batches.map((batch) => batch[0]?.ip)).toEqual(["203.0.113.7", null]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("warns once at startup when the toggle is off", async () => {
|
|
196
|
+
const warnings: string[] = [];
|
|
197
|
+
const logger: AuditLogger = { warn: (_details, message) => warnings.push(message) };
|
|
198
|
+
const sql = await sqliteDatabase();
|
|
199
|
+
await new AuditService(sql, { dialect: "sqlite", enabled: on, logger }).warnIfDisabled();
|
|
200
|
+
await new AuditService(sql, {
|
|
201
|
+
dialect: "sqlite",
|
|
202
|
+
enabled: () => Promise.reject(new Error("store down")),
|
|
203
|
+
logger,
|
|
204
|
+
}).warnIfDisabled();
|
|
205
|
+
expect(warnings).toEqual([]);
|
|
206
|
+
await new AuditService(sql, { dialect: "sqlite", enabled: off, logger }).warnIfDisabled();
|
|
207
|
+
expect(warnings).toEqual([
|
|
208
|
+
"Audit log is off; no audit entries are recorded until it is turned on in the admin Settings",
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
179
212
|
test("a failing toggle read never breaks the caller", async () => {
|
|
180
213
|
const sql = await sqliteDatabase();
|
|
181
214
|
const service = new AuditService(sql, {
|
|
@@ -2,6 +2,7 @@ import type { SQL } from "bun";
|
|
|
2
2
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
3
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
4
4
|
import type { AuthSession } from "../auth/auth.service";
|
|
5
|
+
import { clientAddressOf } from "../core/client-address";
|
|
5
6
|
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
6
7
|
|
|
7
8
|
export interface AuditLogView extends AuditEntry {
|
|
@@ -165,6 +166,24 @@ export class AuditService {
|
|
|
165
166
|
if (this.retention.days > 0 && !this.pruneTimer && !this.pruning) this.schedulePrune(0);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Logs one warning when the toggle is off, because every write is then skipped
|
|
171
|
+
* without a trace. A failing toggle read counts as on, as it does for writes.
|
|
172
|
+
*/
|
|
173
|
+
async warnIfDisabled(): Promise<void> {
|
|
174
|
+
let enabled: boolean;
|
|
175
|
+
try {
|
|
176
|
+
enabled = await this.enabled();
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (enabled) return;
|
|
181
|
+
this.logger?.warn(
|
|
182
|
+
{ AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED ?? null },
|
|
183
|
+
"Audit log is off; no audit entries are recorded until it is turned on in the admin Settings",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
168
187
|
/** Stops pruning and the registry, then writes the queue. */
|
|
169
188
|
async close(): Promise<void> {
|
|
170
189
|
this.closed = true;
|
|
@@ -412,7 +431,7 @@ export class AuditService {
|
|
|
412
431
|
targetId: target.id ?? null,
|
|
413
432
|
targetLabel: target.label ?? null,
|
|
414
433
|
metadata: target.metadata ?? null,
|
|
415
|
-
ip: request.headers
|
|
434
|
+
ip: clientAddressOf(request.headers) ?? null,
|
|
416
435
|
};
|
|
417
436
|
if (options.defer) this.recordLater(entry);
|
|
418
437
|
else await this.record(entry);
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
"requires": ["auth"],
|
|
5
5
|
"targetApp": "api",
|
|
6
6
|
"env": [
|
|
7
|
-
"# audit-log:
|
|
8
|
-
"# and applied without restart
|
|
7
|
+
"# audit-log: on unless switched off. Managed on the admin Settings page,",
|
|
8
|
+
"# stored in the DB, and applied without restart; this value applies until an",
|
|
9
|
+
"# administrator saves the setting. Set it to false to start with the log off.",
|
|
9
10
|
"AUDIT_LOG_ENABLED=true",
|
|
10
11
|
"# audit-log: delete entries older than this many days. Unset or 0 keeps them",
|
|
11
12
|
"# forever; set it only when your retention policy requires deleting them.",
|
|
@@ -31,6 +32,17 @@
|
|
|
31
32
|
"file": "apps/api/src/auth/auth.ts",
|
|
32
33
|
"marker": "// podokit:end:auth-hooks",
|
|
33
34
|
"text": "hooks.after = auditAfterHook;"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"file": "infra/k3s/configmap.yaml",
|
|
38
|
+
"marker": "# podokit:end:runtime-config",
|
|
39
|
+
"text": "AUDIT_LOG_ENABLED: \"true\"",
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"file": "apps/api/src/auth/auth-config-store.ts",
|
|
44
|
+
"marker": "// podokit:end:auth-config-defaults",
|
|
45
|
+
"text": "auditLog: true,"
|
|
34
46
|
}
|
|
35
47
|
],
|
|
36
48
|
"instructions": [
|
|
@@ -39,6 +51,6 @@
|
|
|
39
51
|
"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
52
|
],
|
|
41
53
|
"dependencies": {
|
|
42
|
-
"@podosoft/podokit-auth": "^0.
|
|
54
|
+
"@podosoft/podokit-auth": "^0.6.0"
|
|
43
55
|
}
|
|
44
56
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { envAuthConfig } from "@podosoft/podokit-auth";
|
|
3
|
+
import { buildAuth } from "./auth";
|
|
4
|
+
|
|
5
|
+
interface PluginWithOptions {
|
|
6
|
+
id: string;
|
|
7
|
+
options?: { rpName?: string; issuer?: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function plugin(instance: ReturnType<typeof buildAuth>, id: string): PluginWithOptions | undefined {
|
|
11
|
+
return (instance.options.plugins as PluginWithOptions[]).find((candidate) => candidate.id === id);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("auth app name", () => {
|
|
15
|
+
test("names the app in Better Auth, the TOTP issuer, and the passkey prompt", () => {
|
|
16
|
+
const instance = buildAuth({ ...envAuthConfig(), appName: "Acme Cloud" });
|
|
17
|
+
expect(instance.options.appName).toBe("Acme Cloud");
|
|
18
|
+
expect(plugin(instance, "two-factor")?.options?.issuer).toBe("Acme Cloud");
|
|
19
|
+
expect(plugin(instance, "passkey")?.options?.rpName).toBe("Acme Cloud");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import { createConfigStore, type ConfigStore } from "@podosoft/podokit-auth";
|
|
1
|
+
import { createConfigStore, type AuthConfigDefaults, type ConfigStore } from "@podosoft/podokit-auth";
|
|
2
2
|
import { flagsFrom } from "../settings/flag-defaults";
|
|
3
3
|
import { readAppSettings, readAuthConfig, readConfigVersions } from "./db";
|
|
4
4
|
import { signupOpenFrom } from "./feature-gate";
|
|
5
5
|
|
|
6
|
+
/** Values used when neither the auth_config table nor the environment sets one.
|
|
7
|
+
* Other PodoKit modules add theirs below the marker. */
|
|
8
|
+
export const AUTH_CONFIG_DEFAULTS: AuthConfigDefaults = {
|
|
9
|
+
appName: "{{projectName}}",
|
|
10
|
+
// podokit:begin:auth-config-defaults
|
|
11
|
+
// podokit:end:auth-config-defaults
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
/** A store over this app's auth_config and app_setting tables. */
|
|
7
15
|
export function createAuthConfigStore(): ConfigStore {
|
|
8
16
|
return createConfigStore({
|
|
9
17
|
list: readAuthConfig,
|
|
10
18
|
listSettings: readAppSettings,
|
|
11
19
|
versions: readConfigVersions,
|
|
20
|
+
}, {
|
|
21
|
+
defaults: AUTH_CONFIG_DEFAULTS,
|
|
22
|
+
// The site name from the admin Settings, when the admin dashboard stores one.
|
|
23
|
+
appNameSetting: "site.name",
|
|
12
24
|
});
|
|
13
25
|
}
|
|
14
26
|
|
|
@@ -13,6 +13,11 @@ export function authSecret(): string {
|
|
|
13
13
|
return resolved;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A host that only this machine reaches, where plain HTTP exposes nothing. */
|
|
17
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
18
|
+
return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export function authBaseUrl(): string {
|
|
17
22
|
const value = process.env.BETTER_AUTH_URL?.trim();
|
|
18
23
|
if (process.env.NODE_ENV === "production" && !value) {
|
|
@@ -25,8 +30,10 @@ export function authBaseUrl(): string {
|
|
|
25
30
|
} catch {
|
|
26
31
|
throw new Error("BETTER_AUTH_URL must be a valid URL");
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// A loopback URL may stay HTTP, for example an API that a desktop shell runs
|
|
34
|
+
// on 127.0.0.1 in production mode.
|
|
35
|
+
if (process.env.NODE_ENV === "production" && url.protocol !== "https:" && !isLoopbackHost(url.hostname)) {
|
|
36
|
+
throw new Error("BETTER_AUTH_URL must use HTTPS in production unless it is a loopback address");
|
|
30
37
|
}
|
|
31
38
|
return resolved;
|
|
32
39
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { envAuthConfig, type AuthConfig } from "@podosoft/podokit-auth";
|
|
2
2
|
import { validateEnv } from "../config/env.validation";
|
|
3
3
|
import { auth as bootstrapAuth, buildAuth } from "./auth";
|
|
4
|
-
import { authConfigStore } from "./auth-config-store";
|
|
4
|
+
import { AUTH_CONFIG_DEFAULTS, authConfigStore } from "./auth-config-store";
|
|
5
5
|
import { publishConfigChanged } from "./config-events";
|
|
6
6
|
import { answersWithin, checkAuthResponse } from "./database-outage";
|
|
7
7
|
import { authDatabaseReachable } from "./db";
|
|
8
|
+
import { createSchemaRecheck } from "./schema-recheck";
|
|
8
9
|
|
|
9
10
|
const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
10
11
|
|
|
@@ -16,8 +17,10 @@ const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
|
16
17
|
// Auth instance. Session tokens remain valid because BETTER_AUTH_SECRET is stable.
|
|
17
18
|
|
|
18
19
|
let current = bootstrapAuth;
|
|
20
|
+
let currentConfig: AuthConfig = envAuthConfig(AUTH_CONFIG_DEFAULTS); // the bootstrap instance's
|
|
19
21
|
let currentVersion = "env"; // matches envAuthConfig() bootstrap
|
|
20
22
|
let failedVersion: string | null = null;
|
|
23
|
+
const recheckSchema = createSchemaRecheck();
|
|
21
24
|
|
|
22
25
|
export function getAuth(): typeof bootstrapAuth {
|
|
23
26
|
return current;
|
|
@@ -38,6 +41,7 @@ export async function refreshIfStale(): Promise<void> {
|
|
|
38
41
|
if (config.version === currentVersion || config.version === failedVersion) return;
|
|
39
42
|
try {
|
|
40
43
|
current = buildAuth(config);
|
|
44
|
+
currentConfig = config;
|
|
41
45
|
currentVersion = config.version;
|
|
42
46
|
failedVersion = null;
|
|
43
47
|
} catch (err) {
|
|
@@ -64,7 +68,19 @@ export async function refreshAuthNow(): Promise<void> {
|
|
|
64
68
|
* current instance serves the request, and the refresh finishes in the background.
|
|
65
69
|
*/
|
|
66
70
|
export async function refreshForRequest(): Promise<void> {
|
|
67
|
-
await answersWithin(
|
|
71
|
+
await answersWithin(async () => {
|
|
72
|
+
await refreshIfStale();
|
|
73
|
+
await recheckSchema(current, rebuildCurrent);
|
|
74
|
+
}, REFRESH_WAIT_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A new instance of the current configuration, which checks the schema again. */
|
|
78
|
+
function rebuildCurrent(): void {
|
|
79
|
+
try {
|
|
80
|
+
current = buildAuth(currentConfig);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
|
|
83
|
+
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
// Stable handler reference the adapter captures once; redispatches per request.
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
OPENAPI,
|
|
9
9
|
REQUEST_GUARDS,
|
|
10
10
|
REQUEST_IDENTITY,
|
|
11
|
+
RESOLVED_SESSIONS,
|
|
11
12
|
type AppPlugin,
|
|
12
13
|
type PodokitModule,
|
|
13
14
|
type ServiceKey,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
AuthConfigService,
|
|
17
18
|
type AuthConfigUpdate,
|
|
18
19
|
} from "../auth-config/auth-config.service";
|
|
20
|
+
import { APP_FLAGS } from "../settings/flag-defaults";
|
|
19
21
|
import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
|
|
20
22
|
import { authConfigStore } from "./auth-config-store";
|
|
21
23
|
import { getAuth, authRuntime, primeAuth } from "./auth-provider";
|
|
@@ -100,6 +102,7 @@ const accountPlugin: AppPlugin = ({ services }) => {
|
|
|
100
102
|
auditLog: snapshot.auditLog,
|
|
101
103
|
sessionIdleTimeoutMinutes: snapshot.sessionIdleTimeoutMinutes,
|
|
102
104
|
roles: ROLE_NAMES,
|
|
105
|
+
appFlags: Object.fromEntries(APP_FLAGS.map((flag) => [flag, flags[flag]])),
|
|
103
106
|
};
|
|
104
107
|
}, {
|
|
105
108
|
detail: { tags: ["account"], summary: "Get authentication capabilities" },
|
|
@@ -158,7 +161,9 @@ export const authModule: PodokitModule = {
|
|
|
158
161
|
setMailConfigStore(authConfigStore);
|
|
159
162
|
const accessPolicy = services.resolve(ACCESS_POLICY);
|
|
160
163
|
const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
|
|
161
|
-
const authService = new AuthService(settings, accessPolicy
|
|
164
|
+
const authService = new AuthService(settings, accessPolicy, {
|
|
165
|
+
resolvedSessions: services.resolve(RESOLVED_SESSIONS),
|
|
166
|
+
});
|
|
162
167
|
services.register(SETTINGS, settings);
|
|
163
168
|
services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
|
|
164
169
|
services.register(AUTH, authService, () => authService.close());
|
|
@@ -29,7 +29,17 @@ describe("authentication production environment", () => {
|
|
|
29
29
|
expect(() => authBaseUrl()).toThrow("must be set");
|
|
30
30
|
process.env.BETTER_AUTH_URL = "http://app.example.com";
|
|
31
31
|
expect(() => authBaseUrl()).toThrow("must use HTTPS");
|
|
32
|
+
process.env.BETTER_AUTH_URL = "http://127.0.0.1.example.com";
|
|
33
|
+
expect(() => authBaseUrl()).toThrow("must use HTTPS");
|
|
32
34
|
process.env.BETTER_AUTH_URL = "https://app.example.com";
|
|
33
35
|
expect(authBaseUrl()).toBe("https://app.example.com");
|
|
34
36
|
});
|
|
37
|
+
|
|
38
|
+
test("lets a loopback production origin stay HTTP", () => {
|
|
39
|
+
process.env.NODE_ENV = "production";
|
|
40
|
+
for (const url of ["http://127.0.0.1:5002", "http://localhost:5002", "http://[::1]:5002"]) {
|
|
41
|
+
process.env.BETTER_AUTH_URL = url;
|
|
42
|
+
expect(authBaseUrl()).toBe(url);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
35
45
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { AccessPolicy, type RequestIdentity } from "../core/services";
|
|
2
|
+
import { AccessPolicy, ResolvedSessions, type RequestIdentity, type ResolvedSession } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import {
|
|
5
5
|
AuthService,
|
|
@@ -21,7 +21,7 @@ function session(
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function identityFor(resolver: SessionResolver): RequestIdentity {
|
|
24
|
-
return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
|
|
24
|
+
return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver }));
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
describe("AuthService", () => {
|
|
@@ -33,12 +33,12 @@ describe("AuthService", () => {
|
|
|
33
33
|
test("keeps explicitly public routes unauthenticated", async () => {
|
|
34
34
|
const policy = new AccessPolicy();
|
|
35
35
|
policy.register("GET", "/public", "public");
|
|
36
|
-
const service = new AuthService(settings(false), policy, async () => null);
|
|
36
|
+
const service = new AuthService(settings(false), policy, { resolveSession: async () => null });
|
|
37
37
|
await expect(service.guard(new Request("http://localhost/public"))).resolves.toBeUndefined();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("requires a session by default", async () => {
|
|
41
|
-
const service = new AuthService(settings(false), new AccessPolicy(), async () => null);
|
|
41
|
+
const service = new AuthService(settings(false), new AccessPolicy(), { resolveSession: async () => null });
|
|
42
42
|
const result = service.guard(new Request("http://localhost/private"));
|
|
43
43
|
await expect(result).rejects.toMatchObject({ code: "AUTH_REQUIRED", statusCode: 401 });
|
|
44
44
|
});
|
|
@@ -47,7 +47,7 @@ describe("AuthService", () => {
|
|
|
47
47
|
const service = new AuthService(
|
|
48
48
|
settings(true),
|
|
49
49
|
new AccessPolicy(),
|
|
50
|
-
async () => session({ twoFactorEnabled: false }),
|
|
50
|
+
{ resolveSession: async () => session({ twoFactorEnabled: false }) },
|
|
51
51
|
);
|
|
52
52
|
const result = service.guard(new Request("http://localhost/private"));
|
|
53
53
|
await expect(result).rejects.toMatchObject({ code: "TWO_FACTOR_REQUIRED", statusCode: 403 });
|
|
@@ -57,13 +57,61 @@ describe("AuthService", () => {
|
|
|
57
57
|
const service = new AuthService(
|
|
58
58
|
settings(false),
|
|
59
59
|
new AccessPolicy(),
|
|
60
|
-
async () => session({ role: "user" }),
|
|
60
|
+
{ resolveSession: async () => session({ role: "user" }) },
|
|
61
61
|
);
|
|
62
62
|
await expect(service.requireAdmin(new Request("http://localhost/account/settings")))
|
|
63
63
|
.rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
describe("AuthService resolved sessions", () => {
|
|
68
|
+
const expiresAt = new Date("2030-01-02T03:04:05.000Z");
|
|
69
|
+
|
|
70
|
+
function reporting(resolver: SessionResolver): { service: AuthService; reported: ResolvedSession[] } {
|
|
71
|
+
const resolvedSessions = new ResolvedSessions();
|
|
72
|
+
const reported: ResolvedSession[] = [];
|
|
73
|
+
resolvedSessions.listen((resolved) => reported.push(resolved));
|
|
74
|
+
return {
|
|
75
|
+
service: new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver, resolvedSessions }),
|
|
76
|
+
reported,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("reports each resolved session once per request, before callers continue", async () => {
|
|
81
|
+
const resolver = mock(async () => session({}, { token: "session-token", expiresAt }));
|
|
82
|
+
const { service, reported } = reporting(resolver);
|
|
83
|
+
const request = new Request("http://localhost/private");
|
|
84
|
+
|
|
85
|
+
await service.requireSession(request);
|
|
86
|
+
expect(reported).toEqual([{ token: "session-token", userId: "user-1", expiresAt }]);
|
|
87
|
+
await service.session(request);
|
|
88
|
+
expect(resolver).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(reported).toHaveLength(1);
|
|
90
|
+
|
|
91
|
+
// A serialized expiry is accepted too.
|
|
92
|
+
const serialized = reporting(async () => session({}, { token: "t2", expiresAt: expiresAt.toISOString() }));
|
|
93
|
+
await serialized.service.session(new Request("http://localhost/private"));
|
|
94
|
+
expect(serialized.reported).toEqual([{ token: "t2", userId: "user-1", expiresAt }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("reports nothing without a session, a token, or a valid expiry, and keeps lookup errors", async () => {
|
|
98
|
+
for (const resolver of [
|
|
99
|
+
async () => null,
|
|
100
|
+
async () => session({}, { expiresAt }),
|
|
101
|
+
async () => session({}, { token: "session-token", expiresAt: "soon" }),
|
|
102
|
+
]) {
|
|
103
|
+
const { service, reported } = reporting(resolver);
|
|
104
|
+
await service.session(new Request("http://localhost/private"));
|
|
105
|
+
expect(reported).toEqual([]);
|
|
106
|
+
}
|
|
107
|
+
const failing = reporting(async () => {
|
|
108
|
+
throw new Error("database down");
|
|
109
|
+
});
|
|
110
|
+
await expect(failing.service.session(new Request("http://localhost/private"))).rejects.toThrow("database down");
|
|
111
|
+
expect(failing.reported).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
67
115
|
describe("createRequestIdentity", () => {
|
|
68
116
|
const request = (): Request => new Request("http://localhost/events");
|
|
69
117
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
-
import type { AccessPolicy, RequestIdentity } from "../core/services";
|
|
2
|
+
import type { AccessPolicy, RequestIdentity, ResolvedSessions } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import { getAuth, refreshForRequest } from "./auth-provider";
|
|
5
5
|
import { rethrowAuthError } from "./database-outage";
|
|
@@ -37,19 +37,34 @@ function activeOrganizationOf(session: AuthSession): string | undefined {
|
|
|
37
37
|
return typeof value === "string" && value !== "" ? value : undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export interface AuthServiceOptions {
|
|
41
|
+
/** Looks a request's session up; Better Auth's getSession by default. */
|
|
42
|
+
resolveSession?: SessionResolver;
|
|
43
|
+
/** Receives every session this service resolves, such as for the rate limiter. */
|
|
44
|
+
resolvedSessions?: ResolvedSessions;
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
export class AuthService {
|
|
41
48
|
private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
|
|
49
|
+
private readonly resolveSession: SessionResolver;
|
|
50
|
+
private readonly resolvedSessions: ResolvedSessions | undefined;
|
|
42
51
|
|
|
43
52
|
constructor(
|
|
44
53
|
private readonly settings: SettingsService,
|
|
45
54
|
private readonly accessPolicy: AccessPolicy,
|
|
46
|
-
|
|
47
|
-
) {
|
|
55
|
+
options: AuthServiceOptions = {},
|
|
56
|
+
) {
|
|
57
|
+
this.resolveSession = options.resolveSession ?? defaultSessionResolver;
|
|
58
|
+
this.resolvedSessions = options.resolvedSessions;
|
|
59
|
+
}
|
|
48
60
|
|
|
49
61
|
session(request: Request): Promise<AuthSession | null> {
|
|
50
62
|
const cached = this.sessions.get(request);
|
|
51
63
|
if (cached) return cached;
|
|
52
|
-
const result = this.resolveSession(request.headers)
|
|
64
|
+
const result = this.resolveSession(request.headers).then((session) => {
|
|
65
|
+
if (session) this.report(session);
|
|
66
|
+
return session;
|
|
67
|
+
});
|
|
53
68
|
this.sessions.set(request, result);
|
|
54
69
|
return result;
|
|
55
70
|
}
|
|
@@ -84,6 +99,13 @@ export class AuthService {
|
|
|
84
99
|
async close(): Promise<void> {
|
|
85
100
|
await closeAuthDatabase();
|
|
86
101
|
}
|
|
102
|
+
|
|
103
|
+
private report(session: AuthSession): void {
|
|
104
|
+
const { token, expiresAt } = session.session;
|
|
105
|
+
const expires = expiresAt instanceof Date ? expiresAt : new Date(String(expiresAt));
|
|
106
|
+
if (!this.resolvedSessions || typeof token !== "string" || Number.isNaN(expires.getTime())) return;
|
|
107
|
+
this.resolvedSessions.report({ token, userId: session.user.id, expiresAt: expires });
|
|
108
|
+
}
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
/** Answers identity questions for other modules from the one session lookup memoized per request. */
|