@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.
Files changed (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -1,61 +1,439 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type { SQL } from "bun";
3
- import { recordAudit } from "./audit-events";
4
- import { AuditService } from "./audit.service";
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { QueryRunner } from "typeorm";
4
+ import type { AuthSession } from "../auth/auth.service";
5
+ import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
6
+ import { recordAudit, recordAuditLater } from "./audit-events";
7
+ import { AppException } from "@podosoft/podokit-contracts";
8
+ import { AuditService, parseRetentionDays, type AuditLogger, type AuditLogQuery } from "./audit.service";
5
9
 
6
- function sqlRecorder(rows: unknown[] = []): { sql: SQL; values: unknown[][] } {
7
- const values: unknown[][] = [];
8
- const tag = mock((_strings: TemplateStringsArray, ...parameters: unknown[]) => {
9
- values.push(parameters);
10
- return Promise.resolve(rows);
11
- });
12
- return { sql: tag as unknown as SQL, values };
10
+ const on = (): Promise<boolean> => Promise.resolve(true);
11
+ const off = (): Promise<boolean> => Promise.resolve(false);
12
+
13
+ let open: SQL | undefined;
14
+
15
+ afterEach(async () => {
16
+ await open?.close();
17
+ open = undefined;
18
+ });
19
+
20
+ async function sqliteDatabase(): Promise<SQL> {
21
+ const sql = new SQL(":memory:");
22
+ open = sql;
23
+ const runner = {
24
+ connection: { options: { type: "better-sqlite3" } },
25
+ query: (query: string): Promise<unknown> => sql.unsafe(query),
26
+ } as unknown as QueryRunner;
27
+ await new InitAuditLogs1720300000000().up(runner);
28
+ return sql;
29
+ }
30
+
31
+ interface SeedRow {
32
+ id: string;
33
+ action: string;
34
+ createdAt: string;
35
+ actorEmail?: string;
36
+ targetLabel?: string;
37
+ }
38
+
39
+ const at = (second: number): string => `2026-01-01T00:00:0${second}.000Z`;
40
+
41
+ // Newest first: a5, a4, then a3 and a2 sharing one timestamp, then a1.
42
+ const SEED: SeedRow[] = [
43
+ { id: "a1", action: "auth.login", createdAt: at(1), actorEmail: "admin@example.com" },
44
+ { id: "a2", action: "user.ban", createdAt: at(2), actorEmail: "admin@example.com", targetLabel: "bob@example.com" },
45
+ { id: "a3", action: "auth.login", createdAt: at(2), actorEmail: "bob@example.com" },
46
+ { id: "a4", action: "user.ban", createdAt: at(3), actorEmail: "admin@example.com", targetLabel: "carol@example.com" },
47
+ { id: "a5", action: "auth.login", createdAt: at(4), actorEmail: "admin@example.com" },
48
+ ];
49
+
50
+ async function seededService(): Promise<AuditService> {
51
+ const sql = await sqliteDatabase();
52
+ for (const row of SEED) {
53
+ await sql`
54
+ INSERT INTO "audit_logs" ("id", "action", "actorEmail", "targetLabel", "createdAt")
55
+ VALUES (${row.id}, ${row.action}, ${row.actorEmail ?? null}, ${row.targetLabel ?? null}, ${row.createdAt})
56
+ `;
57
+ }
58
+ return new AuditService(sql, { dialect: "sqlite", enabled: on });
59
+ }
60
+
61
+ async function walk(service: AuditService, query: AuditLogQuery): Promise<string[][]> {
62
+ const pages: string[][] = [];
63
+ let cursor: string | undefined;
64
+ do {
65
+ const page = await service.list({ ...query, cursor });
66
+ pages.push(page.entries.map((entry) => entry.id));
67
+ cursor = page.nextCursor ?? undefined;
68
+ } while (cursor && pages.length < 10);
69
+ return pages;
70
+ }
71
+
72
+ async function storedRows(sql: SQL): Promise<Array<{ action: string; metadata: unknown }>> {
73
+ return sql<Array<{ action: string; metadata: unknown }>>`
74
+ SELECT "action", "metadata" FROM "audit_logs" ORDER BY "createdAt", "action"
75
+ `;
76
+ }
77
+
78
+ // Captures each INSERT's rows as the service binds them, so batching and the
79
+ // PostgreSQL encoding can be checked without a server.
80
+ function sqlCapture(): { sql: SQL; batches: Array<Array<Record<string, unknown>>>; failNext: () => void } {
81
+ const batches: Array<Array<Record<string, unknown>>> = [];
82
+ let fail = false;
83
+ const tag = (first: unknown, ...rest: unknown[]): unknown => {
84
+ if (Array.isArray(first) && "raw" in first) {
85
+ if (fail) {
86
+ fail = false;
87
+ return Promise.reject(new Error("database unavailable"));
88
+ }
89
+ for (const value of rest) {
90
+ const helper = value as { rows?: Array<Record<string, unknown>> };
91
+ if (helper.rows) batches.push(helper.rows);
92
+ }
93
+ return Promise.resolve([]);
94
+ }
95
+ return { rows: first };
96
+ };
97
+ return { sql: tag as unknown as SQL, batches, failNext: () => { fail = true; } };
98
+ }
99
+
100
+ function warnings(): { logger: AuditLogger; messages: string[] } {
101
+ const messages: string[] = [];
102
+ return { logger: { warn: (_details, message) => { messages.push(message); } }, messages };
103
+ }
104
+
105
+ async function waitFor(condition: () => boolean, timeoutMs = 1_000): Promise<void> {
106
+ const deadline = Date.now() + timeoutMs;
107
+ while (!condition()) {
108
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the condition");
109
+ await Bun.sleep(5);
110
+ }
13
111
  }
14
112
 
15
113
  describe("AuditService", () => {
16
114
  test("writes global audit events through one recorder", async () => {
17
- const recorder = sqlRecorder();
18
- const service = new AuditService(recorder.sql);
115
+ const sql = await sqliteDatabase();
116
+ const service = new AuditService(sql, { dialect: "sqlite", enabled: on });
19
117
  service.connect();
20
118
  try {
21
- await recordAudit({
22
- action: "invoice.paid",
23
- actorId: "user-1",
24
- metadata: { amount: 42 },
25
- });
26
- expect(recorder.values).toHaveLength(1);
27
- expect(recorder.values[0]).toContain("invoice.paid");
28
- expect(recorder.values[0]).toContain('{"amount":42}');
29
- expect(recorder.values[0]?.[0]).toBeString();
119
+ await recordAudit({ action: "invoice.paid", actorId: "user-1", metadata: { amount: 42 } });
120
+ const [row] = await storedRows(sql);
121
+ expect(row?.action).toBe("invoice.paid");
122
+ expect(row?.metadata).toBe('{"amount":42}');
30
123
  } finally {
31
- service.close();
124
+ await service.close();
32
125
  }
33
126
  });
34
127
 
35
- test("returns recent entries with serialized timestamps", async () => {
36
- const recorder = sqlRecorder([{
37
- id: "audit-1",
38
- action: "auth.login",
39
- createdAt: new Date("2026-01-02T03:04:05.000Z"),
40
- }]);
41
- const entries = await new AuditService(recorder.sql).recent();
42
- expect(entries[0]?.createdAt).toBe("2026-01-02T03:04:05.000Z");
128
+ test("binds PostgreSQL metadata as an object so jsonb stores an object", async () => {
129
+ const capture = sqlCapture();
130
+ await new AuditService(capture.sql, { dialect: "postgres", enabled: on })
131
+ .record({ action: "invoice.paid", metadata: { amount: 42 } });
132
+ expect(capture.batches).toHaveLength(1);
133
+ expect(capture.batches[0]?.[0]).toMatchObject({ action: "invoice.paid", metadata: { amount: 42 } });
43
134
  });
44
135
 
45
- test("normalizes SQLite timestamps and JSON text", async () => {
46
- const recorder = sqlRecorder([{
47
- id: "audit-1",
48
- action: "auth.login",
49
- metadata: '{"method":"password"}',
50
- createdAt: "2026-01-02T03:04:05.000Z",
51
- }]);
52
- const entries = await new AuditService(recorder.sql).recent();
53
- expect(entries[0]?.metadata).toEqual({ method: "password" });
54
- expect(entries[0]?.createdAt).toBe("2026-01-02T03:04:05.000Z");
136
+ test("lists entries with serialized timestamps and parsed metadata", async () => {
137
+ const sql = await sqliteDatabase();
138
+ const service = new AuditService(sql, { dialect: "sqlite", enabled: on });
139
+ await service.record({ action: "auth.login", metadata: { method: "password" } });
140
+ const [entry] = (await service.list()).entries;
141
+ expect(entry?.metadata).toEqual({ method: "password" });
142
+ expect(entry?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
55
143
  });
56
144
 
57
145
  test("does not propagate persistence failures", async () => {
58
- const sql = mock(() => Promise.reject(new Error("database unavailable"))) as unknown as SQL;
59
- await expect(new AuditService(sql).record({ action: "auth.login" })).resolves.toBeUndefined();
146
+ const sql = mock((first: unknown) =>
147
+ Array.isArray(first) && "raw" in first ? Promise.reject(new Error("database unavailable")) : {}
148
+ ) as unknown as SQL;
149
+ const service = new AuditService(sql, { dialect: "postgres", enabled: on });
150
+ await expect(service.record({ action: "auth.login" })).resolves.toBeUndefined();
151
+ });
152
+
153
+ test("record() skips the write while the toggle is off", async () => {
154
+ const sql = await sqliteDatabase();
155
+ await new AuditService(sql, { dialect: "sqlite", enabled: off }).record({ action: "invoice.paid" });
156
+ expect(await storedRows(sql)).toHaveLength(0);
157
+ });
158
+
159
+ test("every write path goes through the toggle in record()", async () => {
160
+ const sql = await sqliteDatabase();
161
+ let enabled = false;
162
+ const service = new AuditService(sql, { dialect: "sqlite", enabled: () => Promise.resolve(enabled) });
163
+ const session: AuthSession = { user: { id: "user-1", email: "a@example.com" }, session: {} };
164
+ service.connect();
165
+ try {
166
+ await recordAudit({ action: "invoice.paid" });
167
+ await service.recordRequest("todo.create", new Request("http://localhost/todos"), session);
168
+ expect(await storedRows(sql)).toHaveLength(0);
169
+
170
+ enabled = true;
171
+ await recordAudit({ action: "invoice.paid" });
172
+ await service.recordRequest("todo.create", new Request("http://localhost/todos"), session);
173
+ expect((await storedRows(sql)).map((row) => row.action).sort()).toEqual(["invoice.paid", "todo.create"]);
174
+ } finally {
175
+ await service.close();
176
+ }
177
+ });
178
+
179
+ test("a failing toggle read never breaks the caller", async () => {
180
+ const sql = await sqliteDatabase();
181
+ const service = new AuditService(sql, {
182
+ dialect: "sqlite",
183
+ enabled: () => Promise.reject(new Error("store down")),
184
+ });
185
+ await expect(service.record({ action: "auth.login" })).resolves.toBeUndefined();
186
+ });
187
+ });
188
+
189
+ describe("deferred recording", () => {
190
+ test("writes a full batch with one INSERT without waiting for the timer", async () => {
191
+ const capture = sqlCapture();
192
+ const service = new AuditService(capture.sql, {
193
+ dialect: "postgres",
194
+ enabled: on,
195
+ queue: { batchSize: 3, flushIntervalMs: 60_000 },
196
+ });
197
+ for (const id of ["1", "2", "3"]) service.recordLater({ action: "comment.create", targetId: id });
198
+ await waitFor(() => capture.batches.length === 1);
199
+ expect(capture.batches[0]?.map((row) => row.targetId)).toEqual(["1", "2", "3"]);
200
+ await service.close();
201
+ expect(capture.batches).toHaveLength(1);
202
+ });
203
+
204
+ test("writes a partial batch when the flush interval elapses", async () => {
205
+ const capture = sqlCapture();
206
+ const service = new AuditService(capture.sql, {
207
+ dialect: "postgres",
208
+ enabled: on,
209
+ queue: { batchSize: 100, flushIntervalMs: 20 },
210
+ });
211
+ service.recordLater({ action: "comment.create" });
212
+ service.recordLater({ action: "comment.update" });
213
+ expect(capture.batches).toHaveLength(0);
214
+ await waitFor(() => capture.batches.length === 1);
215
+ expect(capture.batches[0]).toHaveLength(2);
216
+ await service.close();
217
+ });
218
+
219
+ test("close() writes the entries still queued", async () => {
220
+ const capture = sqlCapture();
221
+ const service = new AuditService(capture.sql, {
222
+ dialect: "postgres",
223
+ enabled: on,
224
+ queue: { flushIntervalMs: 60_000 },
225
+ });
226
+ service.connect();
227
+ recordAuditLater({ action: "comment.create" });
228
+ service.recordLater({ action: "comment.delete" });
229
+ expect(capture.batches).toHaveLength(0);
230
+ await service.close();
231
+ expect(capture.batches[0]?.map((row) => row.action)).toEqual(["comment.create", "comment.delete"]);
232
+ });
233
+
234
+ test("drops entries past the queue bound and records the loss", async () => {
235
+ const capture = sqlCapture();
236
+ const { logger, messages } = warnings();
237
+ const service = new AuditService(capture.sql, {
238
+ dialect: "postgres",
239
+ enabled: on,
240
+ logger,
241
+ queue: { maxQueued: 2, batchSize: 100, flushIntervalMs: 60_000 },
242
+ });
243
+ for (let index = 0; index < 5; index += 1) service.recordLater({ action: "comment.create" });
244
+ expect(messages).toEqual(["Audit queue is full; dropping deferred entries"]);
245
+ await service.close();
246
+ const batch = capture.batches[0] ?? [];
247
+ expect(batch).toHaveLength(3);
248
+ expect(batch[2]).toMatchObject({ action: "audit.dropped", metadata: { count: 3 } });
249
+ });
250
+
251
+ test("counts a failed batch and records the loss with the next write", async () => {
252
+ const capture = sqlCapture();
253
+ const { logger, messages } = warnings();
254
+ const service = new AuditService(capture.sql, {
255
+ dialect: "postgres",
256
+ enabled: on,
257
+ logger,
258
+ queue: { flushIntervalMs: 60_000 },
259
+ });
260
+ capture.failNext();
261
+ service.recordLater({ action: "comment.create" });
262
+ service.recordLater({ action: "comment.create" });
263
+ await service.flush();
264
+ expect(capture.batches).toHaveLength(0);
265
+ expect(messages).toEqual(["Audit batch write failed"]);
266
+
267
+ service.recordLater({ action: "comment.update" });
268
+ await service.close();
269
+ expect(capture.batches[0]).toEqual([
270
+ expect.objectContaining({ action: "comment.update" }),
271
+ expect.objectContaining({ action: "audit.dropped", metadata: { count: 2 } }),
272
+ ]);
273
+ });
274
+
275
+ test("discards deferred entries while the toggle is off", async () => {
276
+ const capture = sqlCapture();
277
+ const service = new AuditService(capture.sql, { dialect: "postgres", enabled: off });
278
+ service.recordLater({ action: "comment.create" });
279
+ await service.close();
280
+ expect(capture.batches).toHaveLength(0);
281
+ });
282
+
283
+ test("recordRequest() queues the entry when asked to defer", async () => {
284
+ const capture = sqlCapture();
285
+ const service = new AuditService(capture.sql, {
286
+ dialect: "postgres",
287
+ enabled: on,
288
+ queue: { flushIntervalMs: 60_000 },
289
+ });
290
+ const session: AuthSession = { user: { id: "user-1", email: "a@example.com" }, session: {} };
291
+ await service.recordRequest("comment.create", new Request("http://localhost/comments"), session, {}, {
292
+ defer: true,
293
+ });
294
+ expect(capture.batches).toHaveLength(0);
295
+ await service.close();
296
+ expect(capture.batches[0]?.[0]).toMatchObject({ action: "comment.create", actorEmail: "a@example.com" });
297
+ });
298
+ });
299
+
300
+ describe("listing", () => {
301
+ test("walks every entry once, newest first, across a timestamp tie", async () => {
302
+ const service = await seededService();
303
+ expect(await walk(service, { limit: 2 })).toEqual([["a5", "a4"], ["a3", "a2"], ["a1"]]);
304
+ expect(await walk(service, { limit: 3 })).toEqual([["a5", "a4", "a3"], ["a2", "a1"]]);
305
+ });
306
+
307
+ test("gives no cursor when the last page is exactly full", async () => {
308
+ const service = await seededService();
309
+ const page = await service.list({ limit: 5 });
310
+ expect(page.entries).toHaveLength(5);
311
+ expect(page.nextCursor).toBeNull();
312
+ });
313
+
314
+ test("walks oldest first when asked", async () => {
315
+ const service = await seededService();
316
+ expect(await walk(service, { limit: 2, order: "asc" })).toEqual([["a1", "a2"], ["a3", "a4"], ["a5"]]);
317
+ });
318
+
319
+ test("keeps the page size between 1 and 100", async () => {
320
+ const service = await seededService();
321
+ expect((await service.list({ limit: 0 })).entries).toHaveLength(1);
322
+ expect((await service.list({ limit: 1_000 })).entries).toHaveLength(5);
323
+ });
324
+
325
+ test("filters by exact action, actor email, and target label", async () => {
326
+ const service = await seededService();
327
+ const ids = async (query: AuditLogQuery): Promise<string[]> =>
328
+ (await service.list(query)).entries.map((entry) => entry.id);
329
+ expect(await ids({ action: "user.ban" })).toEqual(["a4", "a2"]);
330
+ expect(await ids({ actor: "admin@example.com" })).toEqual(["a5", "a4", "a2", "a1"]);
331
+ expect(await ids({ target: "bob@example.com" })).toEqual(["a2"]);
332
+ expect(await ids({ actor: "admin" })).toEqual([]);
333
+ expect(await walk(service, { action: "auth.login", actor: "admin@example.com", limit: 1 }))
334
+ .toEqual([["a5"], ["a1"]]);
335
+ });
336
+
337
+ test("lists each recorded action code once", async () => {
338
+ const service = await seededService();
339
+ expect(await service.actions()).toEqual(["auth.login", "user.ban"]);
340
+ });
341
+
342
+ test("rejects a cursor it did not issue", async () => {
343
+ const service = await seededService();
344
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
345
+ for (const cursor of ["%%%", encode({ id: "a1" }), encode(["yesterday", "a1"]), encode([at(1), 7])]) {
346
+ const error = await service.list({ cursor }).catch((caught: unknown) => caught);
347
+ expect(error).toBeInstanceOf(AppException);
348
+ expect(error).toMatchObject({ code: "INVALID_CURSOR", statusCode: 400 });
349
+ }
350
+ const postgres = new AuditService(sqlCapture().sql, { dialect: "postgres", enabled: on });
351
+ await expect(postgres.list({ cursor: encode([at(1), "a1"]) })).rejects.toMatchObject({ code: "INVALID_CURSOR" });
352
+ });
353
+ });
354
+
355
+ describe("retention", () => {
356
+ const DAY = 24 * 60 * 60 * 1_000;
357
+ const now = Date.parse("2026-06-01T00:00:00.000Z");
358
+
359
+ async function aged(sql: SQL, ages: number[]): Promise<void> {
360
+ for (const [index, days] of ages.entries()) {
361
+ await sql`
362
+ INSERT INTO "audit_logs" ("id", "action", "createdAt")
363
+ VALUES (${`r${index}`}, ${"auth.login"}, ${new Date(now - days * DAY).toISOString()})
364
+ `;
365
+ }
366
+ }
367
+
368
+ // Counts DELETE statements while passing every call through to the database.
369
+ function counting(sql: SQL): { sql: SQL; deletes: () => number } {
370
+ let deletes = 0;
371
+ const call = sql as unknown as (...args: unknown[]) => unknown;
372
+ const wrapped = (first: unknown, ...rest: unknown[]): unknown => {
373
+ if (Array.isArray(first) && "raw" in first && first.join("").includes("DELETE")) deletes += 1;
374
+ return call(first, ...rest);
375
+ };
376
+ return { sql: wrapped as unknown as SQL, deletes: () => deletes };
377
+ }
378
+
379
+ test("reads AUDIT_LOG_RETENTION_DAYS, keeping entries forever by default", () => {
380
+ expect(parseRetentionDays(undefined)).toBe(0);
381
+ expect(parseRetentionDays(" ")).toBe(0);
382
+ expect(parseRetentionDays("0")).toBe(0);
383
+ expect(parseRetentionDays("90")).toBe(90);
384
+ for (const value of ["-1", "1.5", "ninety"]) expect(() => parseRetentionDays(value)).toThrow();
385
+ });
386
+
387
+ test("deletes old entries in bounded batches and records the prune", async () => {
388
+ const sql = await sqliteDatabase();
389
+ await aged(sql, [40, 39, 38, 35, 31, 29, 1]);
390
+ const { sql: wrapped, deletes } = counting(sql);
391
+ const service = new AuditService(wrapped, {
392
+ dialect: "sqlite",
393
+ enabled: on,
394
+ retention: { days: 30, batchSize: 2 },
395
+ });
396
+ expect(await service.prune(now)).toBe(5);
397
+ expect(deletes()).toBe(3);
398
+ const left = await sql<Array<{ id: string; action: string; metadata: string | null }>>`
399
+ SELECT "id", "action", "metadata" FROM "audit_logs" ORDER BY "id"
400
+ `;
401
+ expect(left.filter((row) => row.action === "auth.login").map((row) => row.id)).toEqual(["r5", "r6"]);
402
+ const pruned = left.find((row) => row.action === "audit.pruned");
403
+ expect(JSON.parse(pruned?.metadata ?? "{}")).toEqual({
404
+ count: 5,
405
+ before: new Date(now - 30 * DAY).toISOString(),
406
+ });
407
+ });
408
+
409
+ test("keeps every entry when no retention period is set", async () => {
410
+ const sql = await sqliteDatabase();
411
+ await aged(sql, [4_000]);
412
+ const { sql: wrapped, deletes } = counting(sql);
413
+ const service = new AuditService(wrapped, { dialect: "sqlite", enabled: on });
414
+ service.connect();
415
+ await service.close();
416
+ expect(await service.prune(now)).toBe(0);
417
+ expect(deletes()).toBe(0);
418
+ expect(await storedRows(sql)).toHaveLength(1);
419
+ });
420
+
421
+ test("prunes from connect() on a timer until close()", async () => {
422
+ const sql = await sqliteDatabase();
423
+ await aged(sql, [400, 400]);
424
+ const service = new AuditService(sql, {
425
+ dialect: "sqlite",
426
+ enabled: off,
427
+ retention: { days: 30, intervalMs: 20 },
428
+ });
429
+ service.connect();
430
+ const deadline = Date.now() + 1_000;
431
+ while ((await storedRows(sql)).length > 0 && Date.now() < deadline) await Bun.sleep(5);
432
+ expect(await storedRows(sql)).toHaveLength(0);
433
+
434
+ await service.close();
435
+ await aged(sql, [400]);
436
+ await Bun.sleep(60);
437
+ expect(await storedRows(sql)).toHaveLength(1);
60
438
  });
61
439
  });