@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,6 +1,7 @@
1
1
  import type { SQL } from "bun";
2
+ import { AppException } from "@podosoft/podokit-contracts";
3
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
2
4
  import type { AuthSession } from "../auth/auth.service";
3
- import { auditEnabled } from "./audit-enabled";
4
5
  import { setAuditRecorder, type AuditEntry } from "./audit-events";
5
6
 
6
7
  export interface AuditLogView extends AuditEntry {
@@ -13,6 +14,56 @@ type AuditLogRow = Omit<AuditLogView, "createdAt" | "metadata"> & {
13
14
  metadata: unknown;
14
15
  };
15
16
 
17
+ // A type alias, not an interface: Bun's insert helper needs an indexable row.
18
+ type AuditRow = {
19
+ id: string;
20
+ action: string;
21
+ actorId: string | null;
22
+ actorName: string | null;
23
+ actorEmail: string | null;
24
+ targetType: string | null;
25
+ targetId: string | null;
26
+ targetLabel: string | null;
27
+ ip: string | null;
28
+ metadata: Record<string, unknown> | string | null;
29
+ createdAt: string;
30
+ };
31
+
32
+ const COLUMNS = [
33
+ "id", "action", "actorId", "actorName", "actorEmail",
34
+ "targetType", "targetId", "targetLabel", "ip", "metadata", "createdAt",
35
+ ] as const satisfies readonly (keyof AuditRow)[];
36
+
37
+ export const DEFAULT_AUDIT_PAGE_SIZE = 50;
38
+ export const MAX_AUDIT_PAGE_SIZE = 100;
39
+ const MAX_LISTED_ACTIONS = 500;
40
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+
42
+ export interface AuditLogQuery {
43
+ /** Opaque position from a previous page's `nextCursor`. */
44
+ cursor?: string;
45
+ limit?: number;
46
+ /** Newest first by default. */
47
+ order?: "asc" | "desc";
48
+ /** Exact action code. */
49
+ action?: string;
50
+ /** Exact actor email. */
51
+ actor?: string;
52
+ /** Exact target label (the email or title the list shows). */
53
+ target?: string;
54
+ }
55
+
56
+ export interface AuditLogPage {
57
+ entries: AuditLogView[];
58
+ /** Cursor for the following page; null on the last page. */
59
+ nextCursor: string | null;
60
+ }
61
+
62
+ interface AuditPosition {
63
+ createdAt: string;
64
+ id: string;
65
+ }
66
+
16
67
  export interface AuditTarget {
17
68
  type?: string;
18
69
  id?: string;
@@ -20,58 +71,329 @@ export interface AuditTarget {
20
71
  metadata?: Record<string, unknown>;
21
72
  }
22
73
 
74
+ export interface AuditQueueOptions {
75
+ /** Deferred entries held in memory; once full, new deferred entries are dropped. */
76
+ maxQueued: number;
77
+ /** Rows per INSERT. A full batch is written without waiting for the timer. */
78
+ batchSize: number;
79
+ flushIntervalMs: number;
80
+ }
81
+
82
+ export const DEFAULT_AUDIT_QUEUE: AuditQueueOptions = {
83
+ maxQueued: 1_000,
84
+ batchSize: 100,
85
+ flushIntervalMs: 250,
86
+ };
87
+
88
+ export interface AuditLogger {
89
+ warn(details: Record<string, unknown>, message: string): void;
90
+ }
91
+
92
+ export interface AuditRetentionOptions {
93
+ /** Entries older than this many days are deleted. 0 keeps them forever. */
94
+ days: number;
95
+ /** Rows per DELETE statement, so a backlog never becomes one long delete. */
96
+ batchSize: number;
97
+ /** Pause between prune runs. */
98
+ intervalMs: number;
99
+ }
100
+
101
+ export const DEFAULT_AUDIT_RETENTION: AuditRetentionOptions = {
102
+ days: 0,
103
+ batchSize: 1_000,
104
+ intervalMs: 60 * 60 * 1_000,
105
+ };
106
+
107
+ const DAY_MS = 24 * 60 * 60 * 1_000;
108
+
109
+ /** Reads AUDIT_LOG_RETENTION_DAYS: unset, empty, or 0 keeps entries forever. */
110
+ export function parseRetentionDays(value: string | undefined): number {
111
+ const text = value?.trim() ?? "";
112
+ if (text === "") return 0;
113
+ const days = Number(text);
114
+ if (!Number.isSafeInteger(days) || days < 0) {
115
+ throw new Error("AUDIT_LOG_RETENTION_DAYS must be a whole number of days; 0 keeps entries forever");
116
+ }
117
+ return days;
118
+ }
119
+
120
+ export interface AuditServiceOptions {
121
+ dialect: DatabaseProviderName;
122
+ /** The admin on/off toggle, read before every write. */
123
+ enabled: () => Promise<boolean>;
124
+ logger?: AuditLogger;
125
+ queue?: Partial<AuditQueueOptions>;
126
+ retention?: Partial<AuditRetentionOptions>;
127
+ }
128
+
129
+ export interface RecordRequestOptions {
130
+ /** Queue the entry instead of awaiting the write. See `recordLater()`. */
131
+ defer?: boolean;
132
+ }
133
+
23
134
  export class AuditService {
24
135
  private unregister?: () => void;
136
+ private readonly dialect: DatabaseProviderName;
137
+ private readonly enabled: () => Promise<boolean>;
138
+ private readonly logger?: AuditLogger;
139
+ private readonly queueOptions: AuditQueueOptions;
140
+ private queue: AuditRow[] = [];
141
+ private dropped = 0;
142
+ private failing = false;
143
+ private flushTimer?: ReturnType<typeof setTimeout>;
144
+ private flushing?: Promise<void>;
145
+ private readonly retention: AuditRetentionOptions;
146
+ private pruneTimer?: ReturnType<typeof setTimeout>;
147
+ private pruning?: Promise<void>;
148
+ private closed = false;
25
149
 
26
- constructor(private readonly sql: SQL) {}
150
+ constructor(private readonly sql: SQL, options: AuditServiceOptions) {
151
+ this.dialect = options.dialect;
152
+ this.enabled = options.enabled;
153
+ this.logger = options.logger;
154
+ this.queueOptions = { ...DEFAULT_AUDIT_QUEUE, ...options.queue };
155
+ this.retention = { ...DEFAULT_AUDIT_RETENTION, ...options.retention };
156
+ }
27
157
 
158
+ /** Registers the recorder and, when a retention period is set, starts pruning. */
28
159
  connect(): void {
29
- this.unregister ??= setAuditRecorder((entry) => this.record(entry));
160
+ this.closed = false;
161
+ this.unregister ??= setAuditRecorder({
162
+ record: (entry) => this.record(entry),
163
+ recordLater: (entry) => this.recordLater(entry),
164
+ });
165
+ if (this.retention.days > 0 && !this.pruneTimer && !this.pruning) this.schedulePrune(0);
30
166
  }
31
167
 
32
- close(): void {
168
+ /** Stops pruning and the registry, then writes the queue. */
169
+ async close(): Promise<void> {
170
+ this.closed = true;
33
171
  this.unregister?.();
34
172
  this.unregister = undefined;
173
+ if (this.pruneTimer) clearTimeout(this.pruneTimer);
174
+ this.pruneTimer = undefined;
175
+ await this.pruning;
176
+ await this.flush();
35
177
  }
36
178
 
37
- async record(entry: AuditEntry): Promise<void> {
38
- try {
39
- const metadata = entry.metadata === undefined || entry.metadata === null
40
- ? null
41
- : JSON.stringify(entry.metadata);
42
- await this.sql`
43
- INSERT INTO "audit_logs" (
44
- "id", "action", "actorId", "actorName", "actorEmail",
45
- "targetType", "targetId", "targetLabel", "ip", "metadata", "createdAt"
46
- ) VALUES (
47
- ${crypto.randomUUID()}, ${entry.action}, ${entry.actorId ?? null}, ${entry.actorName ?? null},
48
- ${entry.actorEmail ?? null}, ${entry.targetType ?? null},
49
- ${entry.targetId ?? null}, ${entry.targetLabel ?? null}, ${entry.ip ?? null},
50
- CAST(${metadata} AS JSON), ${new Date().toISOString()}
179
+ /**
180
+ * Deletes entries older than the retention period, at most `batchSize` rows per
181
+ * statement, until none are left. Returns the number deleted.
182
+ */
183
+ async prune(now: number = Date.now()): Promise<number> {
184
+ if (this.retention.days <= 0) return 0;
185
+ // An ISO string compares correctly with SQLite's text timestamps and casts
186
+ // to timestamptz on PostgreSQL, so one statement serves both.
187
+ const cutoff = new Date(now - this.retention.days * DAY_MS).toISOString();
188
+ let deleted = 0;
189
+ while (!this.closed) {
190
+ // Counted from RETURNING: Bun's SQLite adapter can report zero changes for
191
+ // a DELETE whose subquery starts on its own line.
192
+ const removed = await this.sql<Array<{ id: string }>>`
193
+ DELETE FROM "audit_logs" WHERE "id" IN (
194
+ SELECT "id" FROM "audit_logs"
195
+ WHERE "createdAt" < ${cutoff}
196
+ ORDER BY "createdAt"
197
+ LIMIT ${this.retention.batchSize}
51
198
  )
199
+ RETURNING "id"
52
200
  `;
53
- } catch {
201
+ deleted += removed.length;
202
+ if (removed.length < this.retention.batchSize) break;
203
+ }
204
+ if (deleted > 0) {
205
+ await this.record({ action: "audit.pruned", metadata: { count: deleted, before: cutoff } });
206
+ }
207
+ return deleted;
208
+ }
209
+
210
+ private schedulePrune(delayMs: number): void {
211
+ this.pruneTimer = setTimeout(() => {
212
+ this.pruneTimer = undefined;
213
+ this.pruning = this.prune()
214
+ .then(() => undefined, (error: unknown) => {
215
+ this.logger?.warn({ error: message(error) }, "Audit retention prune failed");
216
+ })
217
+ .finally(() => {
218
+ this.pruning = undefined;
219
+ if (!this.closed) this.schedulePrune(this.retention.intervalMs);
220
+ });
221
+ }, delayMs);
222
+ this.pruneTimer.unref?.();
223
+ }
224
+
225
+ /** Writes the entry before resolving. Use for security events. */
226
+ async record(entry: AuditEntry): Promise<void> {
227
+ try {
228
+ await this.persist([this.row(entry)]);
229
+ } catch (error) {
54
230
  // Audit persistence must never break the operation it describes.
231
+ this.logger?.warn({ action: entry.action, error: message(error) }, "Audit write failed");
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Queues the entry and returns at once. Queued entries are written together
237
+ * every `flushIntervalMs`, as soon as a batch fills, and on `close()`. Entries
238
+ * still queued when the process dies are lost, so keep security events on
239
+ * `record()`.
240
+ */
241
+ recordLater(entry: AuditEntry): void {
242
+ try {
243
+ if (this.queue.length >= this.queueOptions.maxQueued) {
244
+ // Waiting here would turn a slow database into slow requests, which is
245
+ // what deferral avoids. The loss is recorded once the database recovers.
246
+ if (this.dropped === 0) {
247
+ this.logger?.warn(
248
+ { maxQueued: this.queueOptions.maxQueued },
249
+ "Audit queue is full; dropping deferred entries",
250
+ );
251
+ }
252
+ this.dropped += 1;
253
+ return;
254
+ }
255
+ this.queue.push(this.row(entry));
256
+ if (this.queue.length >= this.queueOptions.batchSize) void this.flush();
257
+ else this.scheduleFlush();
258
+ } catch (error) {
259
+ this.logger?.warn({ action: entry.action, error: message(error) }, "Audit entry could not be queued");
260
+ }
261
+ }
262
+
263
+ /** Writes every queued entry, one INSERT per batch. */
264
+ flush(): Promise<void> {
265
+ this.flushing ??= this.drain().finally(() => {
266
+ this.flushing = undefined;
267
+ if (this.queue.length > 0) this.scheduleFlush();
268
+ });
269
+ return this.flushing;
270
+ }
271
+
272
+ private scheduleFlush(): void {
273
+ if (this.flushTimer) return;
274
+ this.flushTimer = setTimeout(() => {
275
+ this.flushTimer = undefined;
276
+ void this.flush();
277
+ }, this.queueOptions.flushIntervalMs);
278
+ this.flushTimer.unref?.();
279
+ }
280
+
281
+ private async drain(): Promise<void> {
282
+ if (this.flushTimer) clearTimeout(this.flushTimer);
283
+ this.flushTimer = undefined;
284
+ while (this.queue.length > 0 || this.dropped > 0) {
285
+ const batch = this.queue.splice(0, this.queueOptions.batchSize);
286
+ const dropped = this.dropped;
287
+ this.dropped = 0;
288
+ if (dropped > 0) batch.push(this.row({ action: "audit.dropped", metadata: { count: dropped } }));
289
+ try {
290
+ await this.persist(batch);
291
+ this.failing = false;
292
+ } catch (error) {
293
+ this.dropped += dropped + batch.length - (dropped > 0 ? 1 : 0);
294
+ if (!this.failing) {
295
+ this.logger?.warn({ entries: this.dropped, error: message(error) }, "Audit batch write failed");
296
+ }
297
+ this.failing = true;
298
+ return;
299
+ }
55
300
  }
56
301
  }
57
302
 
58
- async recent(limit = 50): Promise<AuditLogView[]> {
59
- const rows = await this.sql<AuditLogRow[]>`
303
+ /** The one write path: checks the admin toggle, then inserts the rows. */
304
+ private async persist(rows: AuditRow[]): Promise<void> {
305
+ if (rows.length === 0 || !(await this.enabled())) return;
306
+ await this.sql`INSERT INTO "audit_logs" ${this.sql(rows, ...COLUMNS)}`;
307
+ }
308
+
309
+ private row(entry: AuditEntry): AuditRow {
310
+ const metadata = entry.metadata ?? null;
311
+ return {
312
+ id: crypto.randomUUID(),
313
+ action: entry.action,
314
+ actorId: entry.actorId ?? null,
315
+ actorName: entry.actorName ?? null,
316
+ actorEmail: entry.actorEmail ?? null,
317
+ targetType: entry.targetType ?? null,
318
+ targetId: entry.targetId ?? null,
319
+ targetLabel: entry.targetLabel ?? null,
320
+ ip: entry.ip ?? null,
321
+ // Bun encodes a bound value as JSON for a jsonb column, so a pre-serialized
322
+ // string would be stored as a JSON string. SQLite keeps the text column.
323
+ metadata: metadata === null || this.dialect === "postgres" ? metadata : JSON.stringify(metadata),
324
+ createdAt: new Date().toISOString(),
325
+ };
326
+ }
327
+
328
+ /**
329
+ * One page of entries in ("createdAt", "id") order. Keyset paging keeps every
330
+ * page as cheap as the first, however deep the caller reads.
331
+ */
332
+ async list(query: AuditLogQuery = {}): Promise<AuditLogPage> {
333
+ const sql = this.sql;
334
+ const requested = Number.isFinite(query.limit) ? Math.trunc(query.limit ?? 0) : DEFAULT_AUDIT_PAGE_SIZE;
335
+ const limit = Math.min(Math.max(requested, 1), MAX_AUDIT_PAGE_SIZE);
336
+ const after = query.cursor ? this.decodeCursor(query.cursor) : null;
337
+ const descending = query.order !== "asc";
338
+ const rows = await sql<AuditLogRow[]>`
60
339
  SELECT "id", "action", "actorId", "actorName", "actorEmail",
61
340
  "targetType", "targetId", "targetLabel", "ip", "metadata", "createdAt"
62
341
  FROM "audit_logs"
63
- ORDER BY "createdAt" DESC
64
- LIMIT ${limit}
342
+ WHERE 1 = 1
343
+ ${query.action ? sql`AND "action" = ${query.action}` : sql``}
344
+ ${query.actor ? sql`AND "actorEmail" = ${query.actor}` : sql``}
345
+ ${query.target ? sql`AND "targetLabel" = ${query.target}` : sql``}
346
+ ${after
347
+ ? descending
348
+ ? sql`AND ("createdAt", "id") < (${after.createdAt}, ${after.id})`
349
+ : sql`AND ("createdAt", "id") > (${after.createdAt}, ${after.id})`
350
+ : sql``}
351
+ ${descending
352
+ ? sql`ORDER BY "createdAt" DESC, "id" DESC`
353
+ : sql`ORDER BY "createdAt" ASC, "id" ASC`}
354
+ LIMIT ${limit + 1}
355
+ `;
356
+ const page = rows.slice(0, limit);
357
+ const last = page.at(-1);
358
+ return {
359
+ entries: page.map(view),
360
+ nextCursor: rows.length > limit && last ? encodeCursor(last) : null,
361
+ };
362
+ }
363
+
364
+ /** Distinct action codes, for filter menus. */
365
+ async actions(): Promise<string[]> {
366
+ // A loose index scan: each step seeks the next larger code in the action
367
+ // index, so the cost follows the number of codes rather than rows.
368
+ const rows = await this.sql<Array<{ action: string | null }>>`
369
+ WITH RECURSIVE "codes"("action") AS (
370
+ SELECT MIN("action") FROM "audit_logs"
371
+ UNION ALL
372
+ SELECT (SELECT MIN("action") FROM "audit_logs" WHERE "action" > "codes"."action")
373
+ FROM "codes"
374
+ WHERE "codes"."action" IS NOT NULL
375
+ )
376
+ SELECT "action" FROM "codes" WHERE "action" IS NOT NULL LIMIT ${MAX_LISTED_ACTIONS}
65
377
  `;
66
- return rows.map((row) => ({
67
- ...row,
68
- metadata: typeof row.metadata === "string"
69
- ? JSON.parse(row.metadata) as Record<string, unknown>
70
- : row.metadata as Record<string, unknown> | null,
71
- createdAt: row.createdAt instanceof Date
72
- ? row.createdAt.toISOString()
73
- : new Date(row.createdAt).toISOString(),
74
- }));
378
+ return rows.flatMap((row) => row.action === null ? [] : [row.action]);
379
+ }
380
+
381
+ private decodeCursor(cursor: string): AuditPosition {
382
+ try {
383
+ const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
384
+ if (Array.isArray(value) && value.length === 2) {
385
+ const [createdAt, id] = value as unknown[];
386
+ if (
387
+ typeof createdAt === "string" && !Number.isNaN(Date.parse(createdAt)) &&
388
+ typeof id === "string" && id.length > 0 && (this.dialect !== "postgres" || UUID.test(id))
389
+ ) {
390
+ return { createdAt, id };
391
+ }
392
+ }
393
+ } catch {
394
+ // Malformed base64 or JSON is reported below like any other bad cursor.
395
+ }
396
+ throw new AppException("INVALID_CURSOR", "The audit log cursor is invalid", 400);
75
397
  }
76
398
 
77
399
  async recordRequest(
@@ -79,9 +401,9 @@ export class AuditService {
79
401
  request: Request,
80
402
  session: AuthSession,
81
403
  target: AuditTarget = {},
404
+ options: RecordRequestOptions = {},
82
405
  ): Promise<void> {
83
- if (!(await auditEnabled())) return;
84
- await this.record({
406
+ const entry: AuditEntry = {
85
407
  action,
86
408
  actorId: session.user.id,
87
409
  actorName: typeof session.user.name === "string" ? session.user.name : null,
@@ -91,6 +413,31 @@ export class AuditService {
91
413
  targetLabel: target.label ?? null,
92
414
  metadata: target.metadata ?? null,
93
415
  ip: request.headers.get("x-forwarded-for") ?? null,
94
- });
416
+ };
417
+ if (options.defer) this.recordLater(entry);
418
+ else await this.record(entry);
95
419
  }
96
420
  }
421
+
422
+ function view(row: AuditLogRow): AuditLogView {
423
+ return {
424
+ ...row,
425
+ metadata: typeof row.metadata === "string"
426
+ ? JSON.parse(row.metadata) as Record<string, unknown>
427
+ : row.metadata as Record<string, unknown> | null,
428
+ createdAt: row.createdAt instanceof Date
429
+ ? row.createdAt.toISOString()
430
+ : new Date(row.createdAt).toISOString(),
431
+ };
432
+ }
433
+
434
+ // Keeps the stored timestamp text as is: SQLite compares it as a string, so a
435
+ // reformatted value could skip or repeat rows.
436
+ function encodeCursor(row: AuditLogRow): string {
437
+ const createdAt = row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt;
438
+ return Buffer.from(JSON.stringify([createdAt, row.id])).toString("base64url");
439
+ }
440
+
441
+ function message(error: unknown): string {
442
+ return error instanceof Error ? error.message : String(error);
443
+ }
@@ -0,0 +1,35 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ // Indexes for the keyset list: ("createdAt", "id") serves the unfiltered pages and
4
+ // the retention prune, and each filter gets its column followed by "createdAt".
5
+ // IF NOT EXISTS lets an operator build them beforehand with CREATE INDEX
6
+ // CONCURRENTLY on a large PostgreSQL table; migrations run in one transaction.
7
+ const INDEXES: Array<[name: string, columns: string]> = [
8
+ ["IDX_audit_logs_createdAt_id", `"createdAt", "id"`],
9
+ ["IDX_audit_logs_action_createdAt", `"action", "createdAt"`],
10
+ ["IDX_audit_logs_actorEmail_createdAt", `"actorEmail", "createdAt"`],
11
+ ["IDX_audit_logs_targetLabel_createdAt", `"targetLabel", "createdAt"`],
12
+ ];
13
+
14
+ const REPLACED: Array<[name: string, columns: string]> = [
15
+ ["IDX_audit_logs_action", `"action"`],
16
+ ["IDX_audit_logs_createdAt", `"createdAt"`],
17
+ ];
18
+
19
+ export class IndexAuditLogFilters1792000000000 implements MigrationInterface {
20
+ name = "IndexAuditLogFilters1792000000000";
21
+
22
+ async up(queryRunner: QueryRunner): Promise<void> {
23
+ for (const [name, columns] of INDEXES) {
24
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
25
+ }
26
+ for (const [name] of REPLACED) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
27
+ }
28
+
29
+ async down(queryRunner: QueryRunner): Promise<void> {
30
+ for (const [name, columns] of REPLACED) {
31
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
32
+ }
33
+ for (const [name] of INDEXES) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
34
+ }
35
+ }
@@ -6,7 +6,10 @@
6
6
  "env": [
7
7
  "# audit-log: managed on the admin Settings page, stored in the DB,",
8
8
  "# and applied without restart. This environment value is the fallback.",
9
- "AUDIT_LOG_ENABLED=true"
9
+ "AUDIT_LOG_ENABLED=true",
10
+ "# audit-log: delete entries older than this many days. Unset or 0 keeps them",
11
+ "# forever; set it only when your retention policy requires deleting them.",
12
+ "AUDIT_LOG_RETENTION_DAYS=0"
10
13
  ],
11
14
  "inject": [
12
15
  {
@@ -32,10 +35,10 @@
32
35
  ],
33
36
  "instructions": [
34
37
  "Run migrations to create the audit_logs table: {{apiRun}} migration:run{{apiWorkspaceArg}}",
35
- "Auth/admin actions are recorded by a Better Auth hook. Record semantic application events with AuditService.recordRequest() or recordAudit().",
36
- "List recent entries (admin session required): curl -b cookies.txt localhost:5002/audit-logs"
38
+ "Auth/admin actions are recorded by a Better Auth hook. Record semantic application events with AuditService.recordRequest() or recordAudit(); use recordAuditLater() only for high-volume, non-security events.",
39
+ "List entries newest first (admin session required): curl -i -b cookies.txt 'localhost:5002/audit-logs?limit=50' and pass the X-Next-Cursor header back as ?cursor= for the next page"
37
40
  ],
38
41
  "dependencies": {
39
- "@podosoft/podokit-auth": "^0.4.0"
42
+ "@podosoft/podokit-auth": "^0.5.0"
40
43
  }
41
44
  }
@@ -1,6 +1,21 @@
1
- import { createConfigStore } from "@podosoft/podokit-auth";
2
- import { readAuthConfig } from "./db";
1
+ import { createConfigStore, type ConfigStore } from "@podosoft/podokit-auth";
2
+ import { flagsFrom } from "../settings/flag-defaults";
3
+ import { readAppSettings, readAuthConfig, readConfigVersions } from "./db";
4
+ import { signupOpenFrom } from "./feature-gate";
3
5
 
4
- // One process-wide store keeps the auth runtime and capabilities endpoint on the
5
- // same cache snapshot. Admin writes invalidate this store before rebuilding auth.
6
- export const authConfigStore = createConfigStore({ list: readAuthConfig });
6
+ /** A store over this app's auth_config and app_setting tables. */
7
+ export function createAuthConfigStore(): ConfigStore {
8
+ return createConfigStore({
9
+ list: readAuthConfig,
10
+ listSettings: readAppSettings,
11
+ versions: readConfigVersions,
12
+ });
13
+ }
14
+
15
+ // One process-wide snapshot serves the auth runtime, feature gate, settings
16
+ // service, mailer, capabilities endpoint, and audit toggle. Local writes
17
+ // invalidate it; other replicas see a change at their next version probe.
18
+ export const authConfigStore = createAuthConfigStore();
19
+
20
+ export const featureFlags = authConfigStore.selectSettings(flagsFrom);
21
+ export const isSignupOpen = authConfigStore.selectSettings(signupOpenFrom);
@@ -1,5 +1,7 @@
1
+ import type { AuthConfig } from "@podosoft/podokit-auth";
1
2
  import { auth as bootstrapAuth, buildAuth } from "./auth";
2
3
  import { authConfigStore } from "./auth-config-store";
4
+ import { publishConfigChanged } from "./config-events";
3
5
 
4
6
  // Runtime auth instance that rebuilds when the DB config changes, so admin edits
5
7
  // (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
@@ -8,52 +10,46 @@ import { authConfigStore } from "./auth-config-store";
8
10
  // that refreshes the DB-backed configuration and dispatches to the current Better
9
11
  // Auth instance. Session tokens remain valid because BETTER_AUTH_SECRET is stable.
10
12
 
11
- const TTL_MS = 3_000;
12
-
13
13
  let current = bootstrapAuth;
14
14
  let currentVersion = "env"; // matches envAuthConfig() bootstrap
15
- let checkedAt = 0;
16
- let inflight: Promise<void> | null = null;
15
+ let failedVersion: string | null = null;
17
16
 
18
17
  export function getAuth(): typeof bootstrapAuth {
19
18
  return current;
20
19
  }
21
20
 
22
- /** Rebuild the instance if the DB config version changed. Cheap and throttled to
23
- * TTL; single-flight so concurrent requests trigger at most one rebuild; keeps the
24
- * last-good instance if loading/building fails, so auth never goes down. */
21
+ /** Rebuild the instance if the DB config version changed. The config store
22
+ * throttles its version probe to its TTL and shares one refresh between
23
+ * concurrent callers, so this is cheap per request. Keeps the last-good instance
24
+ * if loading or building fails, so auth never goes down. */
25
25
  export async function refreshIfStale(): Promise<void> {
26
- if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return;
27
- checkedAt = Date.now();
28
- let latest: string;
26
+ let config: AuthConfig;
29
27
  try {
30
- latest = await authConfigStore.currentVersion();
28
+ config = await authConfigStore.load();
31
29
  } catch {
32
30
  return; // DB blip — keep serving the last-good instance
33
31
  }
34
- if (latest === currentVersion) return;
35
- if (inflight) return inflight;
36
- inflight = (async () => {
37
- try {
38
- const config = await authConfigStore.load();
39
- current = buildAuth(config);
40
- currentVersion = config.version;
41
- } catch (err) {
42
- console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
43
- } finally {
44
- inflight = null;
45
- }
46
- })();
47
- return inflight;
32
+ // A config that failed to build is retried only after it changes.
33
+ if (config.version === currentVersion || config.version === failedVersion) return;
34
+ try {
35
+ current = buildAuth(config);
36
+ currentVersion = config.version;
37
+ failedVersion = null;
38
+ } catch (err) {
39
+ failedVersion = config.version;
40
+ console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
41
+ }
48
42
  }
49
43
 
50
- /** Apply a successful in-process admin write before the response is returned. */
44
+ /** Apply a successful in-process admin write before the response is returned,
45
+ * and tell other replicas when an events provider is installed. */
51
46
  export async function refreshAuthNow(): Promise<void> {
52
47
  authConfigStore.invalidate();
53
- checkedAt = 0;
54
- // Force a rebuild even if two writes receive the same millisecond timestamp.
48
+ // Force a rebuild even if the write left the version token unchanged.
55
49
  currentVersion = `stale:${Date.now()}`;
50
+ failedVersion = null;
56
51
  await refreshIfStale();
52
+ await publishConfigChanged();
57
53
  }
58
54
 
59
55
  // Stable handler reference the adapter captures once; redispatches per request.
@@ -72,6 +68,5 @@ export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
72
68
 
73
69
  /** Optional boot preload so the first request doesn't pay the initial DB read. */
74
70
  export async function primeAuth(): Promise<void> {
75
- checkedAt = 0;
76
71
  await refreshIfStale();
77
72
  }