@podosoft/podokit 1.0.1 → 1.1.2
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/README.md +19 -5
- package/dist/add.d.ts +14 -1
- package/dist/add.js +42 -5
- package/dist/create.d.ts +4 -0
- package/dist/create.js +9 -0
- package/dist/deploy-compose-profile.d.ts +3 -0
- package/dist/deploy-compose-profile.js +74 -8
- package/dist/deploy-compose-render.d.ts +2 -1
- package/dist/deploy-compose-render.js +50 -5
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.js +18 -8
- package/dist/deploy-render.js +5 -5
- package/dist/deploy.js +157 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +8 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +22 -4
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +142 -0
- package/dist/remove.js +6 -3
- package/dist/templates/fullstack/AGENTS.md +5 -4
- package/dist/templates/fullstack/README.md +10 -3
- package/dist/templates/fullstack/apps/api/package.json +3 -2
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
- package/dist/templates/fullstack/compose.dev.yaml +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-env.example +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +10 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
- package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
- package/dist/templates/modules/bullmq/module.manifest.json +7 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
- package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
- package/dist/templates/modules/events-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
- package/dist/templates/modules/events-redis/module.manifest.json +32 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/file-upload/module.manifest.json +4 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
- package/dist/templates/modules/job-progress/module.manifest.json +7 -8
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
- package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
- package/dist/templates/modules/mailer/module.manifest.json +2 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
- package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
- package/dist/templates/modules/redis/module.manifest.json +2 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
- package/dist/templates/modules/sse/module.manifest.json +4 -6
- package/dist/templates/todo/AGENTS.md +1 -1
- package/dist/templates/todo/README.md +8 -3
- package/dist/toolchain.d.ts +1 -0
- package/dist/toolchain.js +4 -0
- package/package.json +2 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
- package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import type { Logger } from "pino";
|
|
3
|
+
import type { ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
4
|
import { runUserDeletedHandlers } from "../auth/user-delete-handlers";
|
|
4
|
-
import { StorageService } from "../storage/storage.service";
|
|
5
5
|
import { ProfileImageService } from "./profile-image.service";
|
|
6
6
|
|
|
7
7
|
describe("ProfileImageService account deletion cleanup", () => {
|
|
8
8
|
const storage = {
|
|
9
9
|
put: mock(async () => undefined),
|
|
10
|
-
get: mock(async () =>
|
|
11
|
-
|
|
10
|
+
get: mock(async () => ({
|
|
11
|
+
key: "profile-images/example.webp",
|
|
12
|
+
size: 0,
|
|
13
|
+
body: (async function* (): AsyncGenerator<Uint8Array> { yield Buffer.alloc(0); })(),
|
|
14
|
+
})),
|
|
15
|
+
exists: mock(async () => true),
|
|
16
|
+
getDownloadUrl: mock(async () => "https://example.com/image"),
|
|
12
17
|
delete: mock(async () => undefined),
|
|
18
|
+
close: mock(() => undefined),
|
|
13
19
|
};
|
|
14
20
|
const logger = { warn: mock(() => undefined) } as unknown as Logger;
|
|
15
|
-
const service = new ProfileImageService(storage as unknown as
|
|
21
|
+
const service = new ProfileImageService(storage as unknown as ObjectStore, logger);
|
|
16
22
|
|
|
17
23
|
service.connect();
|
|
18
24
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Logger } from "pino";
|
|
3
|
+
import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
4
|
import {
|
|
4
5
|
AppException,
|
|
5
6
|
PROFILE_IMAGE_NOT_FOUND,
|
|
@@ -7,7 +8,6 @@ import {
|
|
|
7
8
|
} from "@podosoft/podokit-contracts";
|
|
8
9
|
import { getAuth } from "../auth/auth-provider";
|
|
9
10
|
import { registerUserDeletedHandler } from "../auth/user-delete-handlers";
|
|
10
|
-
import type { StorageService } from "../storage/storage.service";
|
|
11
11
|
import { validateProfileImage, type ProfileImageUpload } from "./profile-image.validation";
|
|
12
12
|
|
|
13
13
|
const FILE_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:jpg|png|webp)$/;
|
|
@@ -33,11 +33,17 @@ export type UpdateAuthUser = (
|
|
|
33
33
|
const updateAuthUser: UpdateAuthUser = (image, headers) =>
|
|
34
34
|
getAuth().api.updateUser({ body: { image }, headers });
|
|
35
35
|
|
|
36
|
+
async function objectBuffer(object: ObjectData): Promise<Buffer> {
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
for await (const chunk of object.body) chunks.push(chunk);
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
export class ProfileImageService {
|
|
37
43
|
private unregisterUserDeleted?: () => void;
|
|
38
44
|
|
|
39
45
|
constructor(
|
|
40
|
-
private readonly storage:
|
|
46
|
+
private readonly storage: ObjectStore,
|
|
41
47
|
private readonly logger: Logger,
|
|
42
48
|
private readonly updateUser: UpdateAuthUser = updateAuthUser,
|
|
43
49
|
) {}
|
|
@@ -63,7 +69,7 @@ export class ProfileImageService {
|
|
|
63
69
|
const key = STORAGE_PREFIX + fileName;
|
|
64
70
|
const image = PUBLIC_PREFIX + fileName;
|
|
65
71
|
|
|
66
|
-
await this.storage.put(key, file.buffer, metadata.contentType);
|
|
72
|
+
await this.storage.put(key, file.buffer, { contentType: metadata.contentType });
|
|
67
73
|
try {
|
|
68
74
|
await this.updateUser(image, headers);
|
|
69
75
|
} catch (error: unknown) {
|
|
@@ -86,7 +92,7 @@ export class ProfileImageService {
|
|
|
86
92
|
try {
|
|
87
93
|
const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
|
|
88
94
|
return {
|
|
89
|
-
body: await this.storage.get(key),
|
|
95
|
+
body: await objectBuffer(await this.storage.get(key)),
|
|
90
96
|
contentType: CONTENT_TYPES[extension] ?? "application/octet-stream",
|
|
91
97
|
};
|
|
92
98
|
} catch {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
|
|
3
3
|
|
|
4
4
|
const PREFIX = "site.";
|
|
5
5
|
const FAVICON_KEY = "site/favicon";
|
|
@@ -31,10 +31,16 @@ export interface SiteFavicon {
|
|
|
31
31
|
contentType: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
async function objectBuffer(object: ObjectData): Promise<Buffer> {
|
|
35
|
+
const chunks: Uint8Array[] = [];
|
|
36
|
+
for await (const chunk of object.body) chunks.push(chunk);
|
|
37
|
+
return Buffer.concat(chunks);
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export class SiteSettingsService {
|
|
35
41
|
constructor(
|
|
36
42
|
private readonly sql: SQL,
|
|
37
|
-
private readonly storage:
|
|
43
|
+
private readonly storage: ObjectStore,
|
|
38
44
|
) {}
|
|
39
45
|
|
|
40
46
|
async getAll(): Promise<Record<string, string>> {
|
|
@@ -66,7 +72,7 @@ export class SiteSettingsService {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
async setFavicon(body: Uint8Array, contentType: string): Promise<void> {
|
|
69
|
-
await this.storage.put(FAVICON_KEY, body, contentType);
|
|
75
|
+
await this.storage.put(FAVICON_KEY, body, { contentType });
|
|
70
76
|
await this.setMany({
|
|
71
77
|
faviconContentType: contentType,
|
|
72
78
|
faviconUpdatedAt: String(Date.now()),
|
|
@@ -77,7 +83,7 @@ export class SiteSettingsService {
|
|
|
77
83
|
const contentType = await this.get("faviconContentType");
|
|
78
84
|
if (!contentType) return null;
|
|
79
85
|
try {
|
|
80
|
-
return { body: await this.storage.get(FAVICON_KEY), contentType };
|
|
86
|
+
return { body: await objectBuffer(await this.storage.get(FAVICON_KEY)), contentType };
|
|
81
87
|
} catch {
|
|
82
88
|
return null;
|
|
83
89
|
}
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte
CHANGED
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
const { data: res, error } = await api.auth.twoFactor.enable({ password: twoFaPassword });
|
|
266
266
|
twoFaBusy = false;
|
|
267
267
|
if (error) return void toast.error(error.message ?? i18n.t.account.changeFailed);
|
|
268
|
-
if (!res
|
|
268
|
+
if (!res || !("totpURI" in res) || !res.totpURI || !Array.isArray(res.backupCodes)) {
|
|
269
269
|
return void toast.error(i18n.t.account.changeFailed);
|
|
270
270
|
}
|
|
271
271
|
setup = { totpURI: res.totpURI, backupCodes: res.backupCodes };
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
error = e.code === "INVALID_PASSWORD" ? i18n.t.setup2fa.wrongPassword : i18n.t.setup2fa.enableFailed;
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
|
-
if (!data
|
|
42
|
+
if (!data || !("totpURI" in data) || !data.totpURI || !Array.isArray(data.backupCodes)) {
|
|
43
43
|
error = i18n.t.setup2fa.enableFailed;
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
@@ -14,7 +14,11 @@ const REDIRECT = "https://example.com/callback";
|
|
|
14
14
|
// docs/modules.md for the manual round-trip checklist.
|
|
15
15
|
test("OIDC provider: discovery, JWKS, client registration, authorize handoff", async ({ page, baseURL }) => {
|
|
16
16
|
const headers = { origin: baseURL ?? "" };
|
|
17
|
-
await page.request.put("/api/account/settings", {
|
|
17
|
+
const enabled = await page.request.put("/api/account/settings", {
|
|
18
|
+
data: { oidcProvider: true },
|
|
19
|
+
headers,
|
|
20
|
+
});
|
|
21
|
+
expect(enabled.ok(), await enabled.text()).toBeTruthy();
|
|
18
22
|
await expect
|
|
19
23
|
.poll(async () => (await page.request.get("/api/auth/.well-known/openid-configuration")).status(), { timeout: 8000 })
|
|
20
24
|
.toBe(200);
|
|
@@ -61,6 +65,10 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
|
|
|
61
65
|
await page.goto(consentUrl);
|
|
62
66
|
await expect(page.getByRole("button", { name: "Allow" })).toBeVisible();
|
|
63
67
|
} finally {
|
|
64
|
-
await page.request.put("/api/account/settings", {
|
|
68
|
+
const disabled = await page.request.put("/api/account/settings", {
|
|
69
|
+
data: { oidcProvider: false },
|
|
70
|
+
headers,
|
|
71
|
+
});
|
|
72
|
+
expect(disabled.ok(), await disabled.text()).toBeTruthy();
|
|
65
73
|
}
|
|
66
74
|
});
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"manifestVersion":
|
|
2
|
+
"manifestVersion": 3,
|
|
3
3
|
"name": "admin-dashboard",
|
|
4
4
|
"description": "Admin console UI (SvelteKit + shadcn-svelte): the `(admin)` route group and `admin-sidebar.svelte` are reserved for `/admin/*`; put protected product pages in `(app)` or their own guarded layout. Requires auth.",
|
|
5
|
-
"requires": [
|
|
6
|
-
|
|
7
|
-
"object-storage-s3"
|
|
8
|
-
],
|
|
5
|
+
"requires": ["auth"],
|
|
6
|
+
"requiresCapabilities": ["object-storage"],
|
|
9
7
|
"targetApp": "web",
|
|
10
8
|
"dependencies": {
|
|
11
9
|
"qrcode": "^1.5.4"
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
1
|
+
import { authConfigStore } from "../auth/auth-config-store";
|
|
3
2
|
|
|
4
3
|
// Audit logging is an admin-managed, DB-backed server toggle (auth_config `server`
|
|
5
4
|
// row, env fallback AUDIT_LOG_ENABLED), read through the shared config store's
|
|
6
5
|
// short TTL cache — the same mechanism as the other server-enforced toggles.
|
|
7
6
|
// Toggling it on the Settings page takes effect within the cache TTL, no restart.
|
|
8
|
-
const store = createConfigStore(pool);
|
|
9
|
-
|
|
10
7
|
export async function auditEnabled(): Promise<boolean> {
|
|
11
8
|
try {
|
|
12
|
-
return (await
|
|
9
|
+
return (await authConfigStore.capabilitiesSnapshot()).auditLog;
|
|
13
10
|
} catch {
|
|
14
11
|
return true; // never let a config read failure silently drop the audit trail
|
|
15
12
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { QueryRunner } from "typeorm";
|
|
3
|
+
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
4
|
+
|
|
5
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
6
|
+
return {
|
|
7
|
+
connection: { options: { type } },
|
|
8
|
+
query: (sql: string): Promise<unknown[]> => {
|
|
9
|
+
queries.push(sql);
|
|
10
|
+
return Promise.resolve([]);
|
|
11
|
+
},
|
|
12
|
+
} as unknown as QueryRunner;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("audit log migration", () => {
|
|
16
|
+
it("retains PostgreSQL UUID and JSONB storage", async () => {
|
|
17
|
+
const queries: string[] = [];
|
|
18
|
+
await new InitAuditLogs1720300000000().up(runner("postgres", queries));
|
|
19
|
+
expect(queries[0]).toContain("CREATE EXTENSION");
|
|
20
|
+
expect(queries[1]).toContain("uuid NOT NULL DEFAULT gen_random_uuid()");
|
|
21
|
+
expect(queries[1]).toContain("jsonb");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses portable SQLite column types and defaults", async () => {
|
|
25
|
+
const queries: string[] = [];
|
|
26
|
+
await new InitAuditLogs1720300000000().up(runner("better-sqlite3", queries));
|
|
27
|
+
expect(queries).toHaveLength(3);
|
|
28
|
+
expect(queries[0]).toContain('"id" text NOT NULL');
|
|
29
|
+
expect(queries[0]).toContain('"metadata" text');
|
|
30
|
+
expect(queries[0]).toContain("DEFAULT CURRENT_TIMESTAMP");
|
|
31
|
+
expect(queries.join("\n")).not.toContain("CREATE EXTENSION");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -26,6 +26,7 @@ describe("AuditService", () => {
|
|
|
26
26
|
expect(recorder.values).toHaveLength(1);
|
|
27
27
|
expect(recorder.values[0]).toContain("invoice.paid");
|
|
28
28
|
expect(recorder.values[0]).toContain('{"amount":42}');
|
|
29
|
+
expect(recorder.values[0]?.[0]).toBeString();
|
|
29
30
|
} finally {
|
|
30
31
|
service.close();
|
|
31
32
|
}
|
|
@@ -41,6 +42,18 @@ describe("AuditService", () => {
|
|
|
41
42
|
expect(entries[0]?.createdAt).toBe("2026-01-02T03:04:05.000Z");
|
|
42
43
|
});
|
|
43
44
|
|
|
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");
|
|
55
|
+
});
|
|
56
|
+
|
|
44
57
|
test("does not propagate persistence failures", async () => {
|
|
45
58
|
const sql = mock(() => Promise.reject(new Error("database unavailable"))) as unknown as SQL;
|
|
46
59
|
await expect(new AuditService(sql).record({ action: "auth.login" })).resolves.toBeUndefined();
|
|
@@ -8,9 +8,10 @@ export interface AuditLogView extends AuditEntry {
|
|
|
8
8
|
createdAt: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
createdAt: Date;
|
|
13
|
-
|
|
11
|
+
type AuditLogRow = Omit<AuditLogView, "createdAt" | "metadata"> & {
|
|
12
|
+
createdAt: Date | string;
|
|
13
|
+
metadata: unknown;
|
|
14
|
+
};
|
|
14
15
|
|
|
15
16
|
export interface AuditTarget {
|
|
16
17
|
type?: string;
|
|
@@ -40,13 +41,13 @@ export class AuditService {
|
|
|
40
41
|
: JSON.stringify(entry.metadata);
|
|
41
42
|
await this.sql`
|
|
42
43
|
INSERT INTO "audit_logs" (
|
|
43
|
-
"action", "actorId", "actorName", "actorEmail",
|
|
44
|
-
"targetType", "targetId", "targetLabel", "ip", "metadata"
|
|
44
|
+
"id", "action", "actorId", "actorName", "actorEmail",
|
|
45
|
+
"targetType", "targetId", "targetLabel", "ip", "metadata", "createdAt"
|
|
45
46
|
) VALUES (
|
|
46
|
-
${entry.action}, ${entry.actorId ?? null}, ${entry.actorName ?? null},
|
|
47
|
+
${crypto.randomUUID()}, ${entry.action}, ${entry.actorId ?? null}, ${entry.actorName ?? null},
|
|
47
48
|
${entry.actorEmail ?? null}, ${entry.targetType ?? null},
|
|
48
49
|
${entry.targetId ?? null}, ${entry.targetLabel ?? null}, ${entry.ip ?? null},
|
|
49
|
-
${metadata}
|
|
50
|
+
CAST(${metadata} AS JSON), ${new Date().toISOString()}
|
|
50
51
|
)
|
|
51
52
|
`;
|
|
52
53
|
} catch {
|
|
@@ -62,7 +63,15 @@ export class AuditService {
|
|
|
62
63
|
ORDER BY "createdAt" DESC
|
|
63
64
|
LIMIT ${limit}
|
|
64
65
|
`;
|
|
65
|
-
return rows.map((row) => ({
|
|
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
|
+
}));
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
async recordRequest(
|
|
@@ -4,10 +4,11 @@ export class InitAuditLogs1720300000000 implements MigrationInterface {
|
|
|
4
4
|
name = "InitAuditLogs1720300000000";
|
|
5
5
|
|
|
6
6
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
-
|
|
7
|
+
const sqlite = queryRunner.connection.options.type === "better-sqlite3";
|
|
8
|
+
if (!sqlite) await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
|
|
8
9
|
await queryRunner.query(`
|
|
9
10
|
CREATE TABLE "audit_logs" (
|
|
10
|
-
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
|
11
|
+
"id" ${sqlite ? "text" : "uuid"} NOT NULL${sqlite ? "" : " DEFAULT gen_random_uuid()"},
|
|
11
12
|
"action" character varying(128) NOT NULL,
|
|
12
13
|
"actorId" character varying,
|
|
13
14
|
"actorName" character varying,
|
|
@@ -16,8 +17,8 @@ export class InitAuditLogs1720300000000 implements MigrationInterface {
|
|
|
16
17
|
"targetId" character varying,
|
|
17
18
|
"targetLabel" character varying,
|
|
18
19
|
"ip" character varying,
|
|
19
|
-
"metadata" jsonb,
|
|
20
|
-
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
|
20
|
+
"metadata" ${sqlite ? "text" : "jsonb"},
|
|
21
|
+
"createdAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
|
|
21
22
|
CONSTRAINT "PK_audit_logs_id" PRIMARY KEY ("id")
|
|
22
23
|
)
|
|
23
24
|
`);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SUPPORTED_SOCIAL_PROVIDERS } from "@podosoft/podokit-auth";
|
|
3
|
+
import {
|
|
4
|
+
legacyAccountIssuer,
|
|
5
|
+
legacyAccountIssuerTriggerSql,
|
|
6
|
+
migrateLegacyAccountIssuers,
|
|
7
|
+
type AccountIssuerMigrationClient,
|
|
8
|
+
type AccountIssuerMigrationDatabase,
|
|
9
|
+
} from "./account-issuer-migration";
|
|
10
|
+
|
|
11
|
+
type Statement = { sql: string; values?: readonly unknown[] };
|
|
12
|
+
type LegacyAccountOptions = {
|
|
13
|
+
tableExists?: boolean;
|
|
14
|
+
column?: { dataType: string; isNullable: "YES" | "NO" };
|
|
15
|
+
counts?: {
|
|
16
|
+
blankIssuer?: number;
|
|
17
|
+
credentialMismatch?: number;
|
|
18
|
+
unresolvedIssuer?: number;
|
|
19
|
+
collision?: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class LegacyAccountClient implements AccountIssuerMigrationClient {
|
|
24
|
+
readonly statements: Statement[] = [];
|
|
25
|
+
released = false;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly providers: readonly string[],
|
|
29
|
+
private readonly options: LegacyAccountOptions = {},
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
async query<Row extends Record<string, unknown>>(
|
|
33
|
+
sql: string,
|
|
34
|
+
values?: readonly unknown[],
|
|
35
|
+
): Promise<readonly Row[]> {
|
|
36
|
+
this.statements.push({ sql, ...(values ? { values } : {}) });
|
|
37
|
+
if (sql.includes("information_schema.tables")) {
|
|
38
|
+
return [{ exists: this.options.tableExists ?? true }] as unknown as Row[];
|
|
39
|
+
}
|
|
40
|
+
if (sql.includes("information_schema.columns")) {
|
|
41
|
+
return (this.options.column ? [this.options.column] : []) as unknown as Row[];
|
|
42
|
+
}
|
|
43
|
+
if (sql.includes('SELECT DISTINCT "providerId"')) {
|
|
44
|
+
return this.providers.map((providerId) => ({ providerId })) as unknown as Row[];
|
|
45
|
+
}
|
|
46
|
+
if (sql.includes('COUNT(*)::int AS "count"')) {
|
|
47
|
+
const count = sql.includes('"accountId" <> "userId"')
|
|
48
|
+
? this.options.counts?.credentialMismatch
|
|
49
|
+
: sql.includes("AS collisions")
|
|
50
|
+
? this.options.counts?.collision
|
|
51
|
+
: sql.includes('"issuer" IS NULL OR')
|
|
52
|
+
? this.options.counts?.unresolvedIssuer
|
|
53
|
+
: this.options.counts?.blankIssuer;
|
|
54
|
+
return [{ count: count ?? 0 }] as unknown as Row[];
|
|
55
|
+
}
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
release(): void {
|
|
60
|
+
this.released = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function database(client: LegacyAccountClient): AccountIssuerMigrationDatabase {
|
|
65
|
+
return { connect: async (): Promise<AccountIssuerMigrationClient> => client };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("Better Auth account issuer migration", () => {
|
|
69
|
+
it("maps every safely derivable provider and refuses identities that need trusted external data", () => {
|
|
70
|
+
expect(legacyAccountIssuer("credential")).toBe("local:credential");
|
|
71
|
+
expect(legacyAccountIssuer("google")).toBe("https://accounts.google.com");
|
|
72
|
+
expect(legacyAccountIssuer("apple")).toBe("https://appleid.apple.com");
|
|
73
|
+
expect(legacyAccountIssuer("facebook")).toBe("https://www.facebook.com");
|
|
74
|
+
expect(legacyAccountIssuer("line")).toBe("https://access.line.me");
|
|
75
|
+
expect(legacyAccountIssuer("github")).toBe("local:oauth:github");
|
|
76
|
+
expect(legacyAccountIssuer("microsoft")).toBeUndefined();
|
|
77
|
+
expect(legacyAccountIssuer("unknown-provider")).toBeUndefined();
|
|
78
|
+
|
|
79
|
+
for (const { id } of SUPPORTED_SOCIAL_PROVIDERS) {
|
|
80
|
+
if (id !== "microsoft") expect(legacyAccountIssuer(id), id).toBeDefined();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("installs a fail-closed compatibility trigger for the release that is still serving", () => {
|
|
85
|
+
const sql = legacyAccountIssuerTriggerSql();
|
|
86
|
+
expect(sql).toContain("BEFORE INSERT");
|
|
87
|
+
expect(sql).toContain('WHEN (NEW."issuer" IS NULL)');
|
|
88
|
+
expect(sql).toContain("NEW.\"accountId\" := NEW.\"userId\"");
|
|
89
|
+
expect(sql).toContain("RAISE EXCEPTION");
|
|
90
|
+
expect(sql).not.toContain("WHEN 'microsoft'");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("adds, backfills, validates, constrains, and indexes a legacy account table in one transaction", async () => {
|
|
94
|
+
const client = new LegacyAccountClient(["credential", "google", "github"]);
|
|
95
|
+
|
|
96
|
+
await expect(migrateLegacyAccountIssuers(database(client))).resolves.toBe("migrated");
|
|
97
|
+
|
|
98
|
+
const statements = client.statements.map(({ sql }) => sql.trim());
|
|
99
|
+
expect(statements[0]).toBe("BEGIN");
|
|
100
|
+
expect(statements.some((sql) => sql.includes('ADD COLUMN "issuer" text'))).toBe(true);
|
|
101
|
+
expect(
|
|
102
|
+
client.statements.some(
|
|
103
|
+
({ sql, values }) => sql.includes('UPDATE "account"') && values?.[0] === "local:credential",
|
|
104
|
+
),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
expect(
|
|
107
|
+
client.statements.some(
|
|
108
|
+
({ sql, values }) => sql.includes('UPDATE "account"') && values?.[0] === "https://accounts.google.com",
|
|
109
|
+
),
|
|
110
|
+
).toBe(true);
|
|
111
|
+
expect(statements.some((sql) => sql.includes("CREATE TRIGGER podokit_fill_legacy_account_issuer"))).toBe(
|
|
112
|
+
true,
|
|
113
|
+
);
|
|
114
|
+
expect(statements.some((sql) => sql.includes('ALTER COLUMN "issuer" SET NOT NULL'))).toBe(true);
|
|
115
|
+
expect(statements.some((sql) => sql.includes('CREATE UNIQUE INDEX IF NOT EXISTS'))).toBe(true);
|
|
116
|
+
expect(statements.at(-1)).toBe("COMMIT");
|
|
117
|
+
expect(client.released).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rolls back instead of guessing a Microsoft or unknown issuer", async () => {
|
|
121
|
+
const client = new LegacyAccountClient(["microsoft"]);
|
|
122
|
+
|
|
123
|
+
await expect(migrateLegacyAccountIssuers(database(client))).rejects.toThrow(
|
|
124
|
+
"Cannot infer a trusted Better Auth issuer for legacy provider microsoft",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(client.statements.map(({ sql }) => sql.trim())).toContain("ROLLBACK");
|
|
128
|
+
expect(client.statements.some(({ sql }) => sql.includes('SET NOT NULL'))).toBe(false);
|
|
129
|
+
expect(client.released).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("leaves a fresh database to the normal migrator and protects a current schema", async () => {
|
|
133
|
+
const absent = new LegacyAccountClient([], { tableExists: false });
|
|
134
|
+
const current = new LegacyAccountClient([], {
|
|
135
|
+
column: { dataType: "text", isNullable: "NO" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await expect(migrateLegacyAccountIssuers(database(absent))).resolves.toBe("absent");
|
|
139
|
+
await expect(migrateLegacyAccountIssuers(database(current))).resolves.toBe("current");
|
|
140
|
+
|
|
141
|
+
expect(absent.statements.some(({ sql }) => sql.includes("CREATE TRIGGER"))).toBe(false);
|
|
142
|
+
expect(current.statements.some(({ sql }) => sql.includes("CREATE TRIGGER"))).toBe(true);
|
|
143
|
+
for (const client of [absent, current]) {
|
|
144
|
+
expect(client.statements.at(-1)?.sql.trim()).toBe("COMMIT");
|
|
145
|
+
expect(client.released).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("rolls back before constraints when credentials mismatch or identity keys collide", async () => {
|
|
150
|
+
const mismatch = new LegacyAccountClient(["credential"], {
|
|
151
|
+
counts: { credentialMismatch: 1 },
|
|
152
|
+
});
|
|
153
|
+
const collision = new LegacyAccountClient(["google"], {
|
|
154
|
+
counts: { collision: 1 },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await expect(migrateLegacyAccountIssuers(database(mismatch))).rejects.toThrow(
|
|
158
|
+
"accountId differs from the linked userId",
|
|
159
|
+
);
|
|
160
|
+
await expect(migrateLegacyAccountIssuers(database(collision))).rejects.toThrow(
|
|
161
|
+
"collide on the new issuer and accountId identity key",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
for (const client of [mismatch, collision]) {
|
|
165
|
+
expect(client.statements.map(({ sql }) => sql.trim())).toContain("ROLLBACK");
|
|
166
|
+
expect(client.statements.some(({ sql }) => sql.includes('SET NOT NULL'))).toBe(false);
|
|
167
|
+
expect(client.released).toBe(true);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|