@podosoft/podokit 1.0.2 → 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.
Files changed (112) hide show
  1. package/README.md +19 -5
  2. package/dist/add.d.ts +14 -1
  3. package/dist/add.js +42 -5
  4. package/dist/create.d.ts +4 -0
  5. package/dist/create.js +9 -0
  6. package/dist/deploy-compose-profile.d.ts +3 -0
  7. package/dist/deploy-compose-profile.js +74 -8
  8. package/dist/deploy-compose-render.d.ts +1 -0
  9. package/dist/deploy-compose-render.js +46 -1
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy.js +157 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +41 -0
  15. package/dist/lib.d.ts +2 -0
  16. package/dist/lib.js +8 -2
  17. package/dist/lockfile.d.ts +6 -1
  18. package/dist/lockfile.js +22 -4
  19. package/dist/provider.d.ts +24 -0
  20. package/dist/provider.js +142 -0
  21. package/dist/remove.js +6 -3
  22. package/dist/templates/fullstack/AGENTS.md +5 -4
  23. package/dist/templates/fullstack/README.md +10 -3
  24. package/dist/templates/fullstack/apps/api/package.json +3 -2
  25. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  26. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  27. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  28. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  29. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  30. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  31. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  32. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  34. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  35. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  36. package/dist/templates/fullstack/dot-env.example +2 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  42. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  43. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  46. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  47. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  56. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  57. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  58. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  59. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  60. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  61. package/dist/templates/modules/auth/module.manifest.json +1 -1
  62. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  63. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  65. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  66. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  67. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  68. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  69. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  70. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  71. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  73. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  74. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  75. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  76. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  77. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  78. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  79. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  81. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  82. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  83. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  85. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  86. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  87. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  88. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  89. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  90. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  91. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  92. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  93. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  94. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  95. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  96. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  97. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  98. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  99. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  100. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  101. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  102. package/dist/templates/modules/redis/module.manifest.json +2 -0
  103. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  104. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  106. package/dist/templates/modules/sse/module.manifest.json +4 -6
  107. package/dist/templates/todo/AGENTS.md +1 -1
  108. package/dist/templates/todo/README.md +8 -3
  109. package/package.json +2 -1
  110. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  111. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  112. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -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: StorageService,
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 { StorageService } from "../storage/storage.service";
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: StorageService,
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
  }
@@ -1,11 +1,9 @@
1
1
  {
2
- "manifestVersion": 2,
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
- "auth",
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 { pool } from "../auth/db";
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 store.capabilitiesSnapshot()).auditLog;
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
- interface AuditLogRow extends Omit<AuditLogView, "createdAt"> {
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}::jsonb
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) => ({ ...row, createdAt: row.createdAt.toISOString() }));
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
- await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
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
  `);
@@ -36,6 +36,6 @@
36
36
  "List recent entries (admin session required): curl -b cookies.txt localhost:5002/audit-logs"
37
37
  ],
38
38
  "dependencies": {
39
- "@podosoft/podokit-auth": "^0.3.0"
39
+ "@podosoft/podokit-auth": "^0.4.0"
40
40
  }
41
41
  }
@@ -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
+ });