@podosoft/podokit 1.1.6 → 1.2.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/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- 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 +9 -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/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- 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/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-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/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.module.ts +17 -28
- 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 +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -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/protected-layout.ts +7 -50
- 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 +86 -38
- 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/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- 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/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/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -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 +24 -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 +20 -0
- 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 +81 -0
- 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 +35 -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.service.ts +7 -2
- 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/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/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 +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -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 +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -18,11 +18,35 @@ 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/auth/admin/list-users" && url.searchParams.get("filterField") === "role";
|
|
24
|
+
});
|
|
21
25
|
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
26
|
+
expect(new URL((await filtered).url()).searchParams.get("filterValue")).toBe("admin");
|
|
22
27
|
await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
23
28
|
await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
|
|
24
29
|
});
|
|
25
30
|
|
|
31
|
+
test("users toolbar keeps one filter because the server accepts one", async ({ page }) => {
|
|
32
|
+
await ready(page, "/admin/users");
|
|
33
|
+
const roleSelect = page.locator('button[data-slot="select-trigger"]').first();
|
|
34
|
+
const statusSelect = page.locator('button[data-slot="select-trigger"]').nth(1);
|
|
35
|
+
await roleSelect.click();
|
|
36
|
+
await page.getByRole("option", { name: "Admin", exact: true }).click();
|
|
37
|
+
await statusSelect.click();
|
|
38
|
+
await page.getByRole("option", { name: "Banned", exact: true }).click();
|
|
39
|
+
await expect(roleSelect).toContainText("All");
|
|
40
|
+
await expect(statusSelect).toContainText("Banned");
|
|
41
|
+
|
|
42
|
+
const filtered = page.waitForResponse((response) => {
|
|
43
|
+
const url = new URL(response.url());
|
|
44
|
+
return url.pathname === "/api/auth/admin/list-users" && url.searchParams.get("filterField") === "banned";
|
|
45
|
+
});
|
|
46
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
47
|
+
expect(new URL((await filtered).url()).searchParams.get("filterValue")).toBe("true");
|
|
48
|
+
});
|
|
49
|
+
|
|
26
50
|
test("users toolbar can pick the search field", async ({ page }) => {
|
|
27
51
|
await ready(page, "/admin/users");
|
|
28
52
|
const fieldSelect = page.locator('button[data-slot="select-trigger"]').last(); // after the filters
|
|
@@ -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/auth/admin/list-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,6 +43,26 @@ 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/auth/admin/list-users";
|
|
48
|
+
const first = page.waitForResponse((response) => isListUsers(response.url()));
|
|
49
|
+
await ready(page, "/admin/users");
|
|
50
|
+
const initial = new URL((await first).url()).searchParams;
|
|
51
|
+
expect(initial.get("limit")).toBe("15");
|
|
52
|
+
expect(initial.get("sortBy")).toBe("createdAt");
|
|
53
|
+
expect(initial.get("sortDirection")).toBe("desc");
|
|
54
|
+
await expect(page.getByRole("columnheader", { name: "Joined" })).toHaveAttribute("aria-sort", "descending");
|
|
55
|
+
|
|
56
|
+
const searched = page.waitForResponse((response) => isListUsers(response.url()));
|
|
57
|
+
await page.locator("#toolbar-search").fill("Admin@Example.com");
|
|
58
|
+
await page.getByRole("button", { name: "Search", exact: true }).click();
|
|
59
|
+
const query = new URL((await searched).url()).searchParams;
|
|
60
|
+
expect(query.get("searchValue")).toBe("admin@example.com");
|
|
61
|
+
expect(query.get("searchField")).toBe("email");
|
|
62
|
+
expect(query.get("offset")).toBe("0");
|
|
63
|
+
await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
|
|
64
|
+
});
|
|
65
|
+
|
|
46
66
|
test("user list shows the joined column", async ({ page }) => {
|
|
47
67
|
await ready(page, "/admin/users");
|
|
48
68
|
await expect(page.getByRole("columnheader", { name: "Joined" })).toBeVisible();
|
|
@@ -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,6 +1,9 @@
|
|
|
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
8
|
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
6
9
|
return {
|
|
@@ -12,6 +15,31 @@ function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRu
|
|
|
12
15
|
} as unknown as QueryRunner;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
const FILTER_INDEXES = [
|
|
19
|
+
"IDX_audit_logs_action_createdAt",
|
|
20
|
+
"IDX_audit_logs_actorEmail_createdAt",
|
|
21
|
+
"IDX_audit_logs_createdAt_id",
|
|
22
|
+
"IDX_audit_logs_targetLabel_createdAt",
|
|
23
|
+
];
|
|
24
|
+
const INITIAL_INDEXES = ["IDX_audit_logs_action", "IDX_audit_logs_createdAt"];
|
|
25
|
+
|
|
26
|
+
// Finalized at once: an open statement keeps SQLite from dropping an index.
|
|
27
|
+
function rows<Row>(database: Database, sql: string): Row[] {
|
|
28
|
+
const statement = database.prepare<Row, []>(sql);
|
|
29
|
+
try {
|
|
30
|
+
return statement.all();
|
|
31
|
+
} finally {
|
|
32
|
+
statement.finalize();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function indexNames(database: Database): string[] {
|
|
37
|
+
return rows<{ name: string }>(
|
|
38
|
+
database,
|
|
39
|
+
`SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" = 'audit_logs' AND "name" LIKE 'IDX_%'`,
|
|
40
|
+
).map((row) => row.name).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
describe("audit log migration", () => {
|
|
16
44
|
it("retains PostgreSQL UUID and JSONB storage", async () => {
|
|
17
45
|
const queries: string[] = [];
|
|
@@ -31,3 +59,56 @@ describe("audit log migration", () => {
|
|
|
31
59
|
expect(queries.join("\n")).not.toContain("CREATE EXTENSION");
|
|
32
60
|
});
|
|
33
61
|
});
|
|
62
|
+
|
|
63
|
+
describe("audit filter index migration", () => {
|
|
64
|
+
it("replaces the single-column indexes on PostgreSQL and restores them on revert", async () => {
|
|
65
|
+
const migration = new IndexAuditLogFilters1792000000000();
|
|
66
|
+
const up: string[] = [];
|
|
67
|
+
await migration.up(runner("postgres", up));
|
|
68
|
+
expect(up).toEqual([
|
|
69
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
|
|
70
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
71
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
|
|
72
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
|
|
73
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_action"',
|
|
74
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt"',
|
|
75
|
+
]);
|
|
76
|
+
expect(up.join("\n")).not.toContain("CONCURRENTLY");
|
|
77
|
+
|
|
78
|
+
const down: string[] = [];
|
|
79
|
+
await migration.down(runner("postgres", down));
|
|
80
|
+
expect(down).toEqual([
|
|
81
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action" ON "audit_logs" ("action")',
|
|
82
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt" ON "audit_logs" ("createdAt")',
|
|
83
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt_id"',
|
|
84
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_action_createdAt"',
|
|
85
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_actorEmail_createdAt"',
|
|
86
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_targetLabel_createdAt"',
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("migrates up and down on SQLite", async () => {
|
|
91
|
+
const database = new Database(":memory:");
|
|
92
|
+
const migrations = [
|
|
93
|
+
{ timestamp: 1720300000000, migration: new InitAuditLogs1720300000000() },
|
|
94
|
+
{ timestamp: 1792000000000, migration: new IndexAuditLogFilters1792000000000() },
|
|
95
|
+
];
|
|
96
|
+
try {
|
|
97
|
+
await runSqliteMigrations(database, migrations);
|
|
98
|
+
expect(indexNames(database)).toEqual(FILTER_INDEXES);
|
|
99
|
+
const plan = rows<{ detail: string }>(
|
|
100
|
+
database,
|
|
101
|
+
`EXPLAIN QUERY PLAN SELECT "id" FROM "audit_logs" WHERE "actorEmail" = 'a' ORDER BY "createdAt" DESC LIMIT 5`,
|
|
102
|
+
).map((row) => row.detail).join("\n");
|
|
103
|
+
expect(plan).toContain("IDX_audit_logs_actorEmail_createdAt");
|
|
104
|
+
|
|
105
|
+
expect(await revertLatestSqliteMigration(database, migrations)).toBe(true);
|
|
106
|
+
expect(indexNames(database)).toEqual(INITIAL_INDEXES);
|
|
107
|
+
|
|
108
|
+
await runSqliteMigrations(database, migrations);
|
|
109
|
+
expect(indexNames(database)).toEqual(FILTER_INDEXES);
|
|
110
|
+
} finally {
|
|
111
|
+
database.close();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Elysia, t } from "elysia";
|
|
2
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
3
|
+
import { MAX_AUDIT_PAGE_SIZE, type AuditService } from "./audit.service";
|
|
4
|
+
|
|
5
|
+
/** Carries the cursor for the next page, so the body stays a plain array. */
|
|
6
|
+
export const AUDIT_NEXT_CURSOR_HEADER = "x-next-cursor";
|
|
7
|
+
|
|
8
|
+
const listQuery = t.Object({
|
|
9
|
+
cursor: t.Optional(t.String({ maxLength: 512 })),
|
|
10
|
+
limit: t.Optional(t.Numeric({ minimum: 1, maximum: MAX_AUDIT_PAGE_SIZE, multipleOf: 1 })),
|
|
11
|
+
// Not t.UnionEnum: Elysia fills an absent optional UnionEnum with its first value.
|
|
12
|
+
order: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])),
|
|
13
|
+
action: t.Optional(t.String({ maxLength: 128 })),
|
|
14
|
+
actor: t.Optional(t.String({ maxLength: 512 })),
|
|
15
|
+
target: t.Optional(t.String({ maxLength: 512 })),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function auditRoutes(
|
|
19
|
+
audit: AuditService,
|
|
20
|
+
requireAdmin: (request: Request) => Promise<unknown>,
|
|
21
|
+
): ElysiaPlugin {
|
|
22
|
+
return new Elysia({ name: "podokit.audit" })
|
|
23
|
+
.get("/audit-logs", async ({ request, query, set }) => {
|
|
24
|
+
await requireAdmin(request);
|
|
25
|
+
const page = await audit.list(query);
|
|
26
|
+
if (page.nextCursor) set.headers[AUDIT_NEXT_CURSOR_HEADER] = page.nextCursor;
|
|
27
|
+
return page.entries;
|
|
28
|
+
}, {
|
|
29
|
+
query: listQuery,
|
|
30
|
+
detail: {
|
|
31
|
+
tags: ["audit"],
|
|
32
|
+
summary: "List audit entries",
|
|
33
|
+
description:
|
|
34
|
+
"Newest first unless order=asc. action, actor (email), and target (label) match exactly. " +
|
|
35
|
+
"When more entries follow, the X-Next-Cursor response header holds the cursor for the next page.",
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.get("/audit-logs/actions", async ({ request }) => {
|
|
39
|
+
await requireAdmin(request);
|
|
40
|
+
return audit.actions();
|
|
41
|
+
}, {
|
|
42
|
+
detail: { tags: ["audit"], summary: "List the recorded audit action codes" },
|
|
43
|
+
});
|
|
44
|
+
}
|