@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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
42
|
+
"@podosoft/podokit-auth": "^0.5.0"
|
|
40
43
|
}
|
|
41
44
|
}
|
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
2
|
-
import {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
27
|
-
checkedAt = Date.now();
|
|
28
|
-
let latest: string;
|
|
26
|
+
let config: AuthConfig;
|
|
29
27
|
try {
|
|
30
|
-
|
|
28
|
+
config = await authConfigStore.load();
|
|
31
29
|
} catch {
|
|
32
30
|
return; // DB blip — keep serving the last-good instance
|
|
33
31
|
}
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
}
|