@podosoft/podokit 1.1.6 → 1.3.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/dist/add.d.ts +3 -1
- package/dist/add.js +116 -14
- package/dist/deploy-render.js +5 -0
- package/dist/index.js +8 -0
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +3 -2
- package/dist/lockfile.d.ts +3 -1
- package/dist/lockfile.js +17 -6
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
- package/dist/templates/modules/sse/module.manifest.json +20 -5
- package/dist/update.d.ts +17 -0
- package/dist/update.js +33 -8
- package/package.json +2 -2
|
@@ -1,22 +1,85 @@
|
|
|
1
|
-
import { expect, test } from "
|
|
1
|
+
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
|
+
|
|
6
|
+
function isSessionList(url: string): boolean {
|
|
7
|
+
return new URL(url).pathname === "/api/admin/sessions";
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
// admin storageState (project default) — sessions is an admin-only view
|
|
5
11
|
test("sessions table shows the expires column", async ({ page }) => {
|
|
6
12
|
await ready(page, "/admin/sessions");
|
|
7
13
|
await expect(page.getByRole("columnheader", { name: "Expires" })).toBeVisible();
|
|
8
14
|
});
|
|
9
15
|
|
|
16
|
+
test("sessions page loads one server page without per-user requests or tokens", async ({ page }) => {
|
|
17
|
+
const requested: string[] = [];
|
|
18
|
+
page.on("request", (request) => requested.push(request.url()));
|
|
19
|
+
const listed = page.waitForResponse((response) => isSessionList(response.url()));
|
|
20
|
+
await ready(page, "/admin/sessions");
|
|
21
|
+
const response = await listed;
|
|
22
|
+
|
|
23
|
+
expect(response.status()).toBe(200);
|
|
24
|
+
const body = (await response.json()) as { items: Array<Record<string, unknown>>; total: number };
|
|
25
|
+
expect(body.items.length).toBeLessThanOrEqual(15);
|
|
26
|
+
for (const item of body.items) expect(item).not.toHaveProperty("token");
|
|
27
|
+
await expect(page.getByRole("table", { name: "User sessions" }).getByRole("row").nth(1)).toBeVisible();
|
|
28
|
+
expect(requested.filter(isSessionList)).toHaveLength(1);
|
|
29
|
+
expect(requested.filter((url) => url.includes("/api/auth/admin/list-user-sessions"))).toEqual([]);
|
|
30
|
+
expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
10
33
|
test("admin sees user sessions across users", async ({ page }) => {
|
|
11
34
|
await ready(page, "/admin/sessions");
|
|
12
35
|
await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
|
|
13
36
|
// Search by exact address — robust to pagination as sessions accumulate (the
|
|
14
37
|
// admin account alone can fill the first page). This is the across-users view.
|
|
38
|
+
const searched = page.waitForResponse(
|
|
39
|
+
(response) => isSessionList(response.url()) && new URL(response.url()).searchParams.get("search") === "user@example.com",
|
|
40
|
+
);
|
|
15
41
|
await page.locator("#toolbar-search").fill("user@example.com");
|
|
16
42
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
43
|
+
expect((await searched).ok()).toBeTruthy();
|
|
17
44
|
await expect(page.getByRole("main").getByText("user@example.com").first()).toBeVisible();
|
|
18
45
|
// row actions are available
|
|
19
46
|
await page.getByRole("row", { name: /user@example.com/ }).first().getByRole("button").click();
|
|
20
47
|
await expect(page.getByRole("menuitem", { name: "Revoke session" })).toBeVisible();
|
|
21
48
|
await expect(page.getByRole("menuitem", { name: "Revoke all for user" })).toBeVisible();
|
|
22
49
|
});
|
|
50
|
+
|
|
51
|
+
test("admin revokes a session by id and only the current page reloads", async ({
|
|
52
|
+
page,
|
|
53
|
+
playwright,
|
|
54
|
+
disposableUsers,
|
|
55
|
+
}) => {
|
|
56
|
+
const email = `ui-sess-revoke-${Date.now()}@example.com`;
|
|
57
|
+
await disposableUsers.create({ email, name: "Session Revoke" });
|
|
58
|
+
// Sign the user in on an isolated context so the shared admin cookie is untouched.
|
|
59
|
+
const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
60
|
+
expect((await theirs.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok())
|
|
61
|
+
.toBeTruthy();
|
|
62
|
+
|
|
63
|
+
await ready(page, "/admin/sessions");
|
|
64
|
+
await page.locator("#toolbar-search").fill(email);
|
|
65
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
66
|
+
const row = page.getByRole("row", { name: new RegExp(email) });
|
|
67
|
+
await expect(row).toHaveCount(1);
|
|
68
|
+
|
|
69
|
+
const revoked = page.waitForResponse(
|
|
70
|
+
(response) => response.request().method() === "DELETE" &&
|
|
71
|
+
new URL(response.url()).pathname.startsWith("/api/admin/sessions/"),
|
|
72
|
+
);
|
|
73
|
+
const reloaded = page.waitForResponse(
|
|
74
|
+
(response) => isSessionList(response.url()) && new URL(response.url()).searchParams.get("search") === email,
|
|
75
|
+
);
|
|
76
|
+
await row.getByRole("button").click();
|
|
77
|
+
await page.getByRole("menuitem", { name: "Revoke session" }).click();
|
|
78
|
+
expect((await revoked).status()).toBe(204);
|
|
79
|
+
const reload = await reloaded;
|
|
80
|
+
expect(new URL(reload.url()).searchParams.get("cursor")).toBeNull();
|
|
81
|
+
await expect(page.getByText("Session revoked")).toBeVisible();
|
|
82
|
+
await expect(row).toHaveCount(0);
|
|
83
|
+
expect(await (await theirs.get("/api/auth/get-session")).json()).toBeNull();
|
|
84
|
+
await theirs.dispose();
|
|
85
|
+
});
|
|
@@ -18,7 +18,36 @@ test("users toolbar applies filter + search together on Search", async ({ page }
|
|
|
18
18
|
await page.locator('button[data-slot="select-trigger"]').first().click(); // Role select
|
|
19
19
|
await page.getByRole("option", { name: "Admin", exact: true }).click();
|
|
20
20
|
// Selecting a filter stages it; nothing applies until Search is pressed.
|
|
21
|
+
const filtered = page.waitForResponse((response) => {
|
|
22
|
+
const url = new URL(response.url());
|
|
23
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("role") === "admin";
|
|
24
|
+
});
|
|
21
25
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
26
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
27
|
+
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
28
|
+
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("users toolbar combines the role and status filters", async ({ page }) => {
|
|
32
|
+
await ready(page, "/admin/users");
|
|
33
|
+
const main = page.getByRole("main");
|
|
34
|
+
const roleSelect = page.locator('button[data-slot="select-trigger"]').first();
|
|
35
|
+
const statusSelect = page.locator('button[data-slot="select-trigger"]').nth(1);
|
|
36
|
+
await roleSelect.click();
|
|
37
|
+
await page.getByRole("option", { name: "Admin", exact: true }).click();
|
|
38
|
+
await statusSelect.click();
|
|
39
|
+
await page.getByRole("option", { name: "Active", exact: true }).click();
|
|
40
|
+
await expect(roleSelect).toContainText("Admin");
|
|
41
|
+
await expect(statusSelect).toContainText("Active");
|
|
42
|
+
|
|
43
|
+
const filtered = page.waitForResponse((response) => {
|
|
44
|
+
const url = new URL(response.url());
|
|
45
|
+
return url.pathname === "/api/admin/users" &&
|
|
46
|
+
url.searchParams.get("role") === "admin" &&
|
|
47
|
+
url.searchParams.get("status") === "active";
|
|
48
|
+
});
|
|
49
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
50
|
+
expect((await filtered).ok()).toBeTruthy();
|
|
22
51
|
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
23
52
|
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
24
53
|
});
|
|
@@ -135,9 +135,16 @@ test("users list shows pagination beyond one page", async ({ page, disposableUse
|
|
|
135
135
|
}
|
|
136
136
|
await page.locator("#toolbar-search").fill(prefix);
|
|
137
137
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
138
|
+
await expect(page.getByText("16 users")).toBeVisible();
|
|
138
139
|
const nav = page.getByRole("navigation", { name: "pagination" }).first();
|
|
139
140
|
await expect(nav).toBeVisible();
|
|
140
|
-
|
|
141
|
+
const secondPage = page.waitForResponse((response) => {
|
|
142
|
+
const url = new URL(response.url());
|
|
143
|
+
return url.pathname === "/api/admin/users" && url.searchParams.get("offset") === "15";
|
|
144
|
+
});
|
|
145
|
+
await nav.getByRole("button", { name: "Page 2", exact: true }).first().click();
|
|
146
|
+
expect((await secondPage).ok()).toBeTruthy();
|
|
147
|
+
await expect(page.getByRole("table", { name: "Users" }).locator("tbody tr")).toHaveCount(1);
|
|
141
148
|
});
|
|
142
149
|
|
|
143
150
|
test("manage modal closes from its footer", async ({ page, disposableUsers }) => {
|
|
@@ -43,15 +43,46 @@ test("admin sees the user list and can search", async ({ page }) => {
|
|
|
43
43
|
await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
test("the user list asks the server for one page at a time", async ({ page }) => {
|
|
47
|
+
const isListUsers = (url: string): boolean => new URL(url).pathname === "/api/admin/users";
|
|
48
|
+
const requested: string[] = [];
|
|
49
|
+
page.on("request", (request) => requested.push(request.url()));
|
|
50
|
+
const first = page.waitForResponse((response) => isListUsers(response.url()));
|
|
51
|
+
await ready(page, "/admin/users");
|
|
52
|
+
const initial = new URL((await first).url()).searchParams;
|
|
53
|
+
expect(initial.get("limit")).toBe("15");
|
|
54
|
+
expect(initial.get("sortBy")).toBe("createdAt");
|
|
55
|
+
expect(initial.get("sortDirection")).toBe("desc");
|
|
56
|
+
await expect(page.getByRole("columnheader", { name: "Joined" })).toHaveAttribute("aria-sort", "descending");
|
|
57
|
+
|
|
58
|
+
// The server matches case-insensitively, so the search goes as typed.
|
|
59
|
+
const searched = page.waitForResponse((response) => isListUsers(response.url()));
|
|
60
|
+
await page.locator("#toolbar-search").fill("Admin@Example.com");
|
|
61
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
62
|
+
const query = new URL((await searched).url()).searchParams;
|
|
63
|
+
expect(query.get("search")).toBe("Admin@Example.com");
|
|
64
|
+
expect(query.get("searchField")).toBe("email");
|
|
65
|
+
expect(query.get("offset")).toBe("0");
|
|
66
|
+
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
67
|
+
expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
46
70
|
test("user list shows the joined column", async ({ page }) => {
|
|
47
71
|
await ready(page, "/admin/users");
|
|
48
72
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
|
|
49
73
|
});
|
|
50
74
|
|
|
51
75
|
test("row menu exposes admin actions", async ({ page }) => {
|
|
52
|
-
|
|
76
|
+
// The list shows one server page, so the search must run before the row exists.
|
|
77
|
+
await ready(page, "/admin/users");
|
|
78
|
+
const searched = page.waitForResponse((response) => {
|
|
79
|
+
const url = new URL(response.url());
|
|
80
|
+
return url.pathname === "/api/admin/users" &&
|
|
81
|
+
url.searchParams.get("search") === "user@example.com";
|
|
82
|
+
});
|
|
53
83
|
await page.locator("#toolbar-search").fill("user@example.com");
|
|
54
84
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
85
|
+
expect((await searched).ok()).toBeTruthy();
|
|
55
86
|
const row = page.getByRole("row", { name: /user@example.com/ });
|
|
56
87
|
await row.getByRole("button").click();
|
|
57
88
|
await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"ownedGlobs": [
|
|
15
15
|
"apps/web/src/lib/i18n/locales/**",
|
|
16
|
-
"apps/web/src/lib/i18n/catalogs/app/**"
|
|
16
|
+
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
17
|
+
"apps/web/src/lib/server/public-paths.ts"
|
|
17
18
|
],
|
|
18
19
|
"managedOverrides": [
|
|
19
20
|
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
@@ -117,7 +118,7 @@
|
|
|
117
118
|
"file": "AGENTS.md",
|
|
118
119
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
119
120
|
"optional": true,
|
|
120
|
-
"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- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\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 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"
|
|
121
|
+
"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- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\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- **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"
|
|
121
122
|
},
|
|
122
123
|
{
|
|
123
124
|
"file": "apps/api/src/app.ts",
|
|
@@ -42,7 +42,41 @@ await recordAudit({
|
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Resolve `AUDIT` from the application service registry to call
|
|
45
|
-
`audit.record({ ... })`. Audit writes never break the request
|
|
45
|
+
`audit.record({ ... })`. Audit writes never break the request, and every write
|
|
46
|
+
path checks the admin on/off toggle.
|
|
47
|
+
|
|
48
|
+
## Deferred recording
|
|
49
|
+
|
|
50
|
+
`recordAudit()` and `recordRequest()` await the INSERT, so the response waits for
|
|
51
|
+
the write. For high-volume events that are not security relevant (comments,
|
|
52
|
+
content edits, views), queue the entry instead:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { recordAuditLater } from "../audit/audit-events";
|
|
56
|
+
|
|
57
|
+
recordAuditLater({ action: "comment.create", actorId, targetType: "comment", targetId: comment.id });
|
|
58
|
+
|
|
59
|
+
// or, in a route
|
|
60
|
+
await audit.recordRequest("comment.create", request, session, { type: "comment", id: comment.id }, { defer: true });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Queued entries are written with one multi-row INSERT every 250 ms, as soon as
|
|
64
|
+
100 are waiting, and when the API shuts down cleanly. **Entries still queued
|
|
65
|
+
when the process crashes or is killed are lost.** The queue holds at most 1,000
|
|
66
|
+
entries; past that, new deferred entries are dropped with a warning, and an
|
|
67
|
+
`audit.dropped` entry (`metadata.count`) records the gap once a write succeeds.
|
|
68
|
+
|
|
69
|
+
Keep `auth.*`, `user.*`, and other security events on the awaited path. The
|
|
70
|
+
Better Auth hook always awaits.
|
|
71
|
+
|
|
72
|
+
## Retention
|
|
73
|
+
|
|
74
|
+
Entries are kept forever unless `AUDIT_LOG_RETENTION_DAYS` is set to a whole
|
|
75
|
+
number of days. Then, on startup and about every hour, the API deletes older
|
|
76
|
+
entries in batches of 1,000 rows and records an `audit.pruned` entry with the
|
|
77
|
+
count and cutoff. Leave it unset or `0` unless a written retention policy
|
|
78
|
+
requires deletion: many compliance regimes require a minimum retention period,
|
|
79
|
+
and pruned entries cannot be recovered.
|
|
46
80
|
|
|
47
81
|
## Fields (`AuditEntry`)
|
|
48
82
|
|
|
@@ -53,8 +87,15 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
|
53
87
|
## Customize
|
|
54
88
|
|
|
55
89
|
- **Auth actions:** edit the `ACTIONS` map in `audit-hook.ts`.
|
|
56
|
-
- **Your routes:** call `AuditService.recordRequest()` after successful operations
|
|
90
|
+
- **Your routes:** call `AuditService.recordRequest()` after successful operations;
|
|
91
|
+
pass `{ defer: true }` only for high-volume, non-security events.
|
|
57
92
|
- **Schema:** add columns with a migration, and keep the
|
|
58
|
-
`AuditEntry` type in `audit-events.ts` in sync.
|
|
59
|
-
|
|
60
|
-
|
|
93
|
+
`AuditEntry` type in `audit-events.ts` in sync. A new list filter needs an
|
|
94
|
+
index that starts with its column and ends with `"createdAt"`, like those in
|
|
95
|
+
`1792000000000-IndexAuditLogFilters.ts`.
|
|
96
|
+
- **Reading:** `GET /audit-logs` (admin only) returns a page as an array, newest
|
|
97
|
+
first, filtered by exact `action`, `actor` (email), and `target` (label). Pass
|
|
98
|
+
the `X-Next-Cursor` response header back as `cursor` for the next page; `limit`
|
|
99
|
+
is 1 to 100 (default 50) and `order=asc` reverses the order.
|
|
100
|
+
`GET /audit-logs/actions` lists the recorded action codes. Routes live in
|
|
101
|
+
`audit.routes.ts`.
|
|
@@ -15,22 +15,34 @@ export type AuditEntry = {
|
|
|
15
15
|
metadata?: Record<string, unknown> | null;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
export interface AuditRecorder {
|
|
19
|
+
record(entry: AuditEntry): Promise<void>;
|
|
20
|
+
recordLater(entry: AuditEntry): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let recorder: AuditRecorder | null = null;
|
|
20
24
|
|
|
21
25
|
// AuditService registers the recorder during Elysia configuration. The registry
|
|
22
26
|
// stays available to Better Auth hooks and background jobs.
|
|
23
|
-
export function setAuditRecorder(
|
|
24
|
-
recorder =
|
|
27
|
+
export function setAuditRecorder(next: AuditRecorder): () => void {
|
|
28
|
+
recorder = next;
|
|
25
29
|
return () => {
|
|
26
|
-
if (recorder ===
|
|
30
|
+
if (recorder === next) recorder = null;
|
|
27
31
|
};
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
// Record a custom audit event from anywhere. Awaits the write; a no-op until the
|
|
31
|
-
// AuditService has started. Example:
|
|
35
|
+
// AuditService has started or while the admin toggle is off. Example:
|
|
32
36
|
// await recordAudit({ action: "invoice.paid", actorId, targetLabel: invoice.number,
|
|
33
37
|
// metadata: { amount } });
|
|
34
38
|
export async function recordAudit(entry: AuditEntry): Promise<void> {
|
|
35
|
-
if (recorder) await recorder(entry);
|
|
39
|
+
if (recorder) await recorder.record(entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Queue a high-volume, non-security event and return at once. Queued entries are
|
|
43
|
+
// written in batches within about 250 ms and on shutdown; a crash loses whatever
|
|
44
|
+
// is still queued. Keep auth.*, user.*, and other security events on recordAudit().
|
|
45
|
+
// recordAuditLater({ action: "comment.create", actorId, targetId: comment.id });
|
|
46
|
+
export function recordAuditLater(entry: AuditEntry): void {
|
|
47
|
+
recorder?.recordLater(entry);
|
|
36
48
|
}
|
|
@@ -39,6 +39,8 @@ const ACTIONS: Record<string, string> = {
|
|
|
39
39
|
export const auditAfterHook = createAuthMiddleware(async (ctx) => {
|
|
40
40
|
const action = ACTIONS[ctx.path];
|
|
41
41
|
if (!action) return;
|
|
42
|
+
// AuditService checks the toggle again on write; checking here as well skips
|
|
43
|
+
// the target lookup below when auditing is off.
|
|
42
44
|
if (!(await auditEnabled())) return;
|
|
43
45
|
const context = ctx.context as {
|
|
44
46
|
session?: { user?: { id?: string; name?: string; email?: string } };
|
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { QueryRunner } from "typeorm";
|
|
4
|
+
import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
|
|
3
5
|
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
6
|
+
import { IndexAuditLogFilters1792000000000 } from "../migrations/1792000000000-IndexAuditLogFilters";
|
|
4
7
|
|
|
5
|
-
function runner(
|
|
8
|
+
function runner(
|
|
9
|
+
type: "postgres" | "better-sqlite3",
|
|
10
|
+
queries: string[],
|
|
11
|
+
invalidIndexes: string[] = [],
|
|
12
|
+
): QueryRunner {
|
|
6
13
|
return {
|
|
7
14
|
connection: { options: { type } },
|
|
8
|
-
query: (sql: string): Promise<unknown[]> => {
|
|
15
|
+
query: (sql: string, parameters: unknown[] = []): Promise<unknown[]> => {
|
|
9
16
|
queries.push(sql);
|
|
10
|
-
|
|
17
|
+
const invalid = sql.includes("indisvalid") && invalidIndexes.some((name) => parameters[0] === `"${name}"`);
|
|
18
|
+
return Promise.resolve(invalid ? [{ invalid: 1 }] : []);
|
|
11
19
|
},
|
|
12
20
|
} as unknown as QueryRunner;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
const INVALID_CHECK =
|
|
24
|
+
"SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid";
|
|
25
|
+
|
|
26
|
+
const FILTER_INDEXES = [
|
|
27
|
+
"IDX_audit_logs_action_createdAt",
|
|
28
|
+
"IDX_audit_logs_actorEmail_createdAt",
|
|
29
|
+
"IDX_audit_logs_createdAt_id",
|
|
30
|
+
"IDX_audit_logs_targetLabel_createdAt",
|
|
31
|
+
];
|
|
32
|
+
const INITIAL_INDEXES = ["IDX_audit_logs_action", "IDX_audit_logs_createdAt"];
|
|
33
|
+
|
|
34
|
+
// Finalized at once: an open statement keeps SQLite from dropping an index.
|
|
35
|
+
function rows<Row>(database: Database, sql: string): Row[] {
|
|
36
|
+
const statement = database.prepare<Row, []>(sql);
|
|
37
|
+
try {
|
|
38
|
+
return statement.all();
|
|
39
|
+
} finally {
|
|
40
|
+
statement.finalize();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function indexNames(database: Database): string[] {
|
|
45
|
+
return rows<{ name: string }>(
|
|
46
|
+
database,
|
|
47
|
+
`SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" = 'audit_logs' AND "name" LIKE 'IDX_%'`,
|
|
48
|
+
).map((row) => row.name).sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
describe("audit log migration", () => {
|
|
16
52
|
it("retains PostgreSQL UUID and JSONB storage", async () => {
|
|
17
53
|
const queries: string[] = [];
|
|
@@ -31,3 +67,90 @@ describe("audit log migration", () => {
|
|
|
31
67
|
expect(queries.join("\n")).not.toContain("CREATE EXTENSION");
|
|
32
68
|
});
|
|
33
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("audit filter index migration", () => {
|
|
72
|
+
it("builds and drops the PostgreSQL indexes concurrently outside a transaction", async () => {
|
|
73
|
+
const migration = new IndexAuditLogFilters1792000000000();
|
|
74
|
+
// A transaction block would reject CONCURRENTLY; the runner must use "each".
|
|
75
|
+
expect(migration.transaction).toBe(false);
|
|
76
|
+
const up: string[] = [];
|
|
77
|
+
await migration.up(runner("postgres", up));
|
|
78
|
+
expect(up).toEqual([
|
|
79
|
+
INVALID_CHECK,
|
|
80
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
|
|
81
|
+
INVALID_CHECK,
|
|
82
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
83
|
+
INVALID_CHECK,
|
|
84
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
|
|
85
|
+
INVALID_CHECK,
|
|
86
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
|
|
87
|
+
'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action"',
|
|
88
|
+
'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_createdAt"',
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rebuilds an invalid index left by an interrupted concurrent build", async () => {
|
|
93
|
+
const up: string[] = [];
|
|
94
|
+
await new IndexAuditLogFilters1792000000000().up(
|
|
95
|
+
runner("postgres", up, ["IDX_audit_logs_action_createdAt"]),
|
|
96
|
+
);
|
|
97
|
+
const drop = up.indexOf('DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action_createdAt"');
|
|
98
|
+
expect(drop).toBeGreaterThan(-1);
|
|
99
|
+
expect(up[drop + 1]).toBe(
|
|
100
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
101
|
+
);
|
|
102
|
+
expect(up.filter((sql) => sql.startsWith("DROP INDEX CONCURRENTLY"))).toHaveLength(3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("restores the single-column PostgreSQL indexes on revert inside its transaction", async () => {
|
|
106
|
+
const migration = new IndexAuditLogFilters1792000000000();
|
|
107
|
+
const down: string[] = [];
|
|
108
|
+
await migration.down(runner("postgres", down));
|
|
109
|
+
expect(down).toEqual([
|
|
110
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action" ON "audit_logs" ("action")',
|
|
111
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt" ON "audit_logs" ("createdAt")',
|
|
112
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt_id"',
|
|
113
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_action_createdAt"',
|
|
114
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_actorEmail_createdAt"',
|
|
115
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_targetLabel_createdAt"',
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps plain index statements on SQLite", async () => {
|
|
120
|
+
const up: string[] = [];
|
|
121
|
+
await new IndexAuditLogFilters1792000000000().up(runner("better-sqlite3", up));
|
|
122
|
+
expect(up).toEqual([
|
|
123
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
|
|
124
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
125
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
|
|
126
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
|
|
127
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_action"',
|
|
128
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt"',
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("migrates up and down on SQLite", async () => {
|
|
133
|
+
const database = new Database(":memory:");
|
|
134
|
+
const migrations = [
|
|
135
|
+
{ timestamp: 1720300000000, migration: new InitAuditLogs1720300000000() },
|
|
136
|
+
{ timestamp: 1792000000000, migration: new IndexAuditLogFilters1792000000000() },
|
|
137
|
+
];
|
|
138
|
+
try {
|
|
139
|
+
await runSqliteMigrations(database, migrations);
|
|
140
|
+
expect(indexNames(database)).toEqual(FILTER_INDEXES);
|
|
141
|
+
const plan = rows<{ detail: string }>(
|
|
142
|
+
database,
|
|
143
|
+
`EXPLAIN QUERY PLAN SELECT "id" FROM "audit_logs" WHERE "actorEmail" = 'a' ORDER BY "createdAt" DESC LIMIT 5`,
|
|
144
|
+
).map((row) => row.detail).join("\n");
|
|
145
|
+
expect(plan).toContain("IDX_audit_logs_actorEmail_createdAt");
|
|
146
|
+
|
|
147
|
+
expect(await revertLatestSqliteMigration(database, migrations)).toBe(true);
|
|
148
|
+
expect(indexNames(database)).toEqual(INITIAL_INDEXES);
|
|
149
|
+
|
|
150
|
+
await runSqliteMigrations(database, migrations);
|
|
151
|
+
expect(indexNames(database)).toEqual(FILTER_INDEXES);
|
|
152
|
+
} finally {
|
|
153
|
+
database.close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,31 +1,33 @@
|
|
|
1
|
-
import { Elysia } from "elysia";
|
|
2
1
|
import { AUTH } from "../auth/auth.module";
|
|
3
2
|
import {
|
|
4
3
|
DATABASE,
|
|
4
|
+
LOGGER,
|
|
5
5
|
type AppPlugin,
|
|
6
6
|
type PodokitModule,
|
|
7
7
|
type ServiceKey,
|
|
8
8
|
} from "../core/services";
|
|
9
|
-
import {
|
|
9
|
+
import { auditEnabled } from "./audit-enabled";
|
|
10
|
+
import { auditRoutes } from "./audit.routes";
|
|
11
|
+
import { AuditService, parseRetentionDays } from "./audit.service";
|
|
10
12
|
|
|
11
13
|
export const AUDIT = Symbol("audit") as ServiceKey<AuditService>;
|
|
12
14
|
|
|
13
15
|
const auditPlugin: AppPlugin = ({ services }) => {
|
|
14
16
|
const auth = services.resolve(AUTH);
|
|
15
|
-
|
|
16
|
-
return new Elysia({ name: "podokit.audit" })
|
|
17
|
-
.get("/audit-logs", async ({ request }) => {
|
|
18
|
-
await auth.requireAdmin(request);
|
|
19
|
-
return audit.recent();
|
|
20
|
-
}, {
|
|
21
|
-
detail: { tags: ["audit"], summary: "List recent audit entries" },
|
|
22
|
-
});
|
|
17
|
+
return auditRoutes(services.resolve(AUDIT), (request) => auth.requireAdmin(request));
|
|
23
18
|
};
|
|
24
19
|
|
|
25
20
|
export const auditModule: PodokitModule = {
|
|
26
21
|
name: "audit-log",
|
|
27
22
|
configure: (_env, services): void => {
|
|
28
|
-
const
|
|
23
|
+
const database = services.resolve(DATABASE);
|
|
24
|
+
const audit = new AuditService(database.sql, {
|
|
25
|
+
dialect: database.provider,
|
|
26
|
+
enabled: auditEnabled,
|
|
27
|
+
// Resolved on use: the logging module may replace the logger after this point.
|
|
28
|
+
logger: { warn: (details, message) => services.resolve(LOGGER).warn(details, message) },
|
|
29
|
+
retention: { days: parseRetentionDays(process.env.AUDIT_LOG_RETENTION_DAYS) },
|
|
30
|
+
});
|
|
29
31
|
services.register(AUDIT, audit, () => audit.close());
|
|
30
32
|
services.onStart(() => audit.connect());
|
|
31
33
|
},
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { QueryRunner } from "typeorm";
|
|
6
|
+
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
7
|
+
import { AUDIT_NEXT_CURSOR_HEADER, auditRoutes } from "./audit.routes";
|
|
8
|
+
import { AuditService } from "./audit.service";
|
|
9
|
+
|
|
10
|
+
let open: SQL | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await open?.close();
|
|
14
|
+
open = undefined;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function service(entries: number): Promise<AuditService> {
|
|
18
|
+
const sql = new SQL(":memory:");
|
|
19
|
+
open = sql;
|
|
20
|
+
const runner = {
|
|
21
|
+
connection: { options: { type: "better-sqlite3" } },
|
|
22
|
+
query: (query: string): Promise<unknown> => sql.unsafe(query),
|
|
23
|
+
} as unknown as QueryRunner;
|
|
24
|
+
await new InitAuditLogs1720300000000().up(runner);
|
|
25
|
+
for (let index = 0; index < entries; index += 1) {
|
|
26
|
+
await sql`
|
|
27
|
+
INSERT INTO "audit_logs" ("id", "action", "actorEmail", "createdAt")
|
|
28
|
+
VALUES (${`e${index}`}, ${index % 2 ? "user.ban" : "auth.login"}, ${"admin@example.com"},
|
|
29
|
+
${new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString()})
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
return new AuditService(sql, { dialect: "sqlite", enabled: () => Promise.resolve(true) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Get = (path: string) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
function app(audit: AuditService, admin = true): Get {
|
|
38
|
+
const server = new Elysia()
|
|
39
|
+
.onError(({ code, error, set }) => {
|
|
40
|
+
if (error instanceof AppException) {
|
|
41
|
+
set.status = error.statusCode;
|
|
42
|
+
return { code: error.code };
|
|
43
|
+
}
|
|
44
|
+
if (code === "VALIDATION") {
|
|
45
|
+
set.status = 400;
|
|
46
|
+
return { code: "VALIDATION_ERROR" };
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
})
|
|
50
|
+
.use(auditRoutes(audit, () =>
|
|
51
|
+
admin
|
|
52
|
+
? Promise.resolve()
|
|
53
|
+
: Promise.reject(new AppException("ADMIN_REQUIRED", "Administrator access is required", 403))
|
|
54
|
+
));
|
|
55
|
+
return (path) => server.handle(new Request(`http://localhost${path}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("GET /audit-logs", () => {
|
|
59
|
+
test("returns an array and the next cursor in a header until the last page", async () => {
|
|
60
|
+
const get = app(await service(5));
|
|
61
|
+
const seen: string[] = [];
|
|
62
|
+
let path = "/audit-logs?limit=2";
|
|
63
|
+
for (let pages = 0; pages < 5; pages += 1) {
|
|
64
|
+
const response = await get(path);
|
|
65
|
+
expect(response.status).toBe(200);
|
|
66
|
+
const body = (await response.json()) as Array<{ id: string }>;
|
|
67
|
+
expect(Array.isArray(body)).toBe(true);
|
|
68
|
+
seen.push(...body.map((entry) => entry.id));
|
|
69
|
+
const next = response.headers.get(AUDIT_NEXT_CURSOR_HEADER);
|
|
70
|
+
if (!next) break;
|
|
71
|
+
path = `/audit-logs?limit=2&cursor=${encodeURIComponent(next)}`;
|
|
72
|
+
}
|
|
73
|
+
expect(seen).toEqual(["e4", "e3", "e2", "e1", "e0"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("lists newest first unless order=asc", async () => {
|
|
77
|
+
const get = app(await service(3));
|
|
78
|
+
const ids = async (path: string): Promise<string[]> =>
|
|
79
|
+
((await (await get(path)).json()) as Array<{ id: string }>).map((entry) => entry.id);
|
|
80
|
+
expect(await ids("/audit-logs")).toEqual(["e2", "e1", "e0"]);
|
|
81
|
+
expect(await ids("/audit-logs?order=desc")).toEqual(["e2", "e1", "e0"]);
|
|
82
|
+
expect(await ids("/audit-logs?order=asc")).toEqual(["e0", "e1", "e2"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("filters on the server", async () => {
|
|
86
|
+
const get = app(await service(4));
|
|
87
|
+
const body = (await (await get("/audit-logs?action=user.ban")).json()) as Array<{ action: string }>;
|
|
88
|
+
expect(body.map((entry) => entry.action)).toEqual(["user.ban", "user.ban"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects out-of-range or malformed parameters", async () => {
|
|
92
|
+
const get = app(await service(1));
|
|
93
|
+
for (const query of ["limit=101", "limit=0", "limit=1.5", "order=sideways"]) {
|
|
94
|
+
const response = await get(`/audit-logs?${query}`);
|
|
95
|
+
expect(response.status).toBe(400);
|
|
96
|
+
expect(await response.json()).toEqual({ code: "VALIDATION_ERROR" });
|
|
97
|
+
}
|
|
98
|
+
const response = await get("/audit-logs?cursor=not-a-cursor");
|
|
99
|
+
expect(response.status).toBe(400);
|
|
100
|
+
expect(await response.json()).toEqual({ code: "INVALID_CURSOR" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("is admin-only", async () => {
|
|
104
|
+
const get = app(await service(1), false);
|
|
105
|
+
expect((await get("/audit-logs")).status).toBe(403);
|
|
106
|
+
expect((await get("/audit-logs/actions")).status).toBe(403);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("GET /audit-logs/actions", () => {
|
|
111
|
+
test("lists the recorded action codes", async () => {
|
|
112
|
+
const get = app(await service(3));
|
|
113
|
+
expect(await (await get("/audit-logs/actions")).json()).toEqual(["auth.login", "user.ban"]);
|
|
114
|
+
});
|
|
115
|
+
});
|