@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
@@ -0,0 +1,260 @@
1
+ import type { Pool } from "pg";
2
+
3
+ type MigrationRow = Record<string, unknown>;
4
+
5
+ export interface AccountIssuerMigrationClient {
6
+ query<Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]>;
7
+ release(): void;
8
+ }
9
+
10
+ export interface AccountIssuerMigrationDatabase {
11
+ connect(): Promise<AccountIssuerMigrationClient>;
12
+ }
13
+
14
+ export type AccountIssuerMigrationResult = "absent" | "current" | "migrated";
15
+
16
+ const LEGACY_ACCOUNT_ISSUERS = new Map<string, string>([
17
+ ["credential", "local:credential"],
18
+ ["google", "https://accounts.google.com"],
19
+ ["github", "local:oauth:github"],
20
+ ["apple", "https://appleid.apple.com"],
21
+ ["facebook", "https://www.facebook.com"],
22
+ ["discord", "local:oauth:discord"],
23
+ ["gitlab", "local:oauth:gitlab"],
24
+ ["linkedin", "local:oauth:linkedin"],
25
+ ["twitch", "local:oauth:twitch"],
26
+ ["spotify", "local:oauth:spotify"],
27
+ ["dropbox", "local:oauth:dropbox"],
28
+ ["kakao", "local:oauth:kakao"],
29
+ ["naver", "local:oauth:naver"],
30
+ ["line", "https://access.line.me"],
31
+ ["slack", "local:oauth:slack"],
32
+ ["notion", "local:oauth:notion"],
33
+ ["twitter", "local:oauth:twitter"],
34
+ ["tiktok", "local:oauth:tiktok"],
35
+ ["reddit", "local:oauth:reddit"],
36
+ ["zoom", "local:oauth:zoom"],
37
+ ["figma", "local:oauth:figma"],
38
+ ["salesforce", "local:oauth:salesforce"],
39
+ ["atlassian", "local:oauth:atlassian"],
40
+ ["kick", "local:oauth:kick"],
41
+ ]);
42
+
43
+ /**
44
+ * Resolve only identities Better Auth can migrate without consulting mutable or
45
+ * unverified profile data. Microsoft is deliberately absent: its 1.7 identity
46
+ * changes from `sub` to the directory `oid`, which needs a trusted external map.
47
+ */
48
+ export function legacyAccountIssuer(providerId: string): string | undefined {
49
+ return LEGACY_ACCOUNT_ISSUERS.get(providerId);
50
+ }
51
+
52
+ function sqlLiteral(value: string): string {
53
+ return `'${value.replaceAll("'", "''")}'`;
54
+ }
55
+
56
+ /** Keep the release that is still serving able to insert accounts during rollout. */
57
+ export function legacyAccountIssuerTriggerSql(): string {
58
+ const cases = [...LEGACY_ACCOUNT_ISSUERS.entries()]
59
+ .map(([providerId, issuer]) => ` WHEN ${sqlLiteral(providerId)} THEN ${sqlLiteral(issuer)}`)
60
+ .join("\n");
61
+
62
+ return `
63
+ CREATE OR REPLACE FUNCTION public.podokit_fill_legacy_account_issuer()
64
+ RETURNS trigger
65
+ LANGUAGE plpgsql
66
+ AS $podokit$
67
+ BEGIN
68
+ IF NEW."issuer" IS NOT NULL THEN
69
+ RETURN NEW;
70
+ END IF;
71
+
72
+ NEW."issuer" := CASE NEW."providerId"
73
+ ${cases}
74
+ ELSE NULL
75
+ END;
76
+
77
+ IF NEW."issuer" IS NULL THEN
78
+ RAISE EXCEPTION 'Cannot infer Better Auth issuer for legacy provider %', NEW."providerId"
79
+ USING ERRCODE = '23502',
80
+ HINT = 'Add an explicit trusted issuer mapping before creating this account.';
81
+ END IF;
82
+
83
+ IF NEW."providerId" = 'credential' THEN
84
+ NEW."accountId" := NEW."userId";
85
+ END IF;
86
+
87
+ RETURN NEW;
88
+ END
89
+ $podokit$;
90
+
91
+ DROP TRIGGER IF EXISTS podokit_fill_legacy_account_issuer ON "account";
92
+ CREATE TRIGGER podokit_fill_legacy_account_issuer
93
+ BEFORE INSERT ON "account"
94
+ FOR EACH ROW
95
+ WHEN (NEW."issuer" IS NULL)
96
+ EXECUTE FUNCTION public.podokit_fill_legacy_account_issuer();
97
+ `;
98
+ }
99
+
100
+ function readCount(rows: readonly MigrationRow[], label: string): number {
101
+ const value = rows[0]?.count;
102
+ if (typeof value === "number" && Number.isSafeInteger(value)) return value;
103
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
104
+ throw new Error(`Cannot read ${label} count during the Better Auth account migration.`);
105
+ }
106
+
107
+ export function postgresAccountIssuerMigrationDatabase(pool: Pool): AccountIssuerMigrationDatabase {
108
+ return {
109
+ connect: async (): Promise<AccountIssuerMigrationClient> => {
110
+ const client = await pool.connect();
111
+ return {
112
+ query: async <Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]> => {
113
+ const result = await client.query<Row>(sql, values === undefined ? undefined : [...values]);
114
+ return result.rows;
115
+ },
116
+ release: (): void => client.release(),
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Upgrade a populated pre-1.7 Better Auth account table without guessing identity.
124
+ *
125
+ * The compatibility trigger is installed in the same transaction as the NOT NULL
126
+ * constraint. Writes from the release that is still serving either receive the
127
+ * exact 1.7 issuer or fail closed for an identity that requires a trusted manual
128
+ * mapping; they can never create a corrupt empty issuer during the rollout.
129
+ */
130
+ export async function migrateLegacyAccountIssuers(
131
+ database: AccountIssuerMigrationDatabase,
132
+ ): Promise<AccountIssuerMigrationResult> {
133
+ const client = await database.connect();
134
+ let transactionOpen = false;
135
+
136
+ try {
137
+ await client.query("BEGIN");
138
+ transactionOpen = true;
139
+ await client.query("SELECT pg_advisory_xact_lock(1886350955, 1)");
140
+
141
+ const tableRows = await client.query<{ exists: boolean }>(`
142
+ SELECT EXISTS (
143
+ SELECT 1
144
+ FROM information_schema.tables
145
+ WHERE table_schema = 'public' AND table_name = 'account'
146
+ ) AS "exists"
147
+ `);
148
+ if (tableRows[0]?.exists !== true) {
149
+ await client.query("COMMIT");
150
+ transactionOpen = false;
151
+ return "absent";
152
+ }
153
+
154
+ const columnRows = await client.query<{ dataType: string; isNullable: "YES" | "NO" }>(`
155
+ SELECT data_type AS "dataType", is_nullable AS "isNullable"
156
+ FROM information_schema.columns
157
+ WHERE table_schema = 'public' AND table_name = 'account' AND column_name = 'issuer'
158
+ `);
159
+ const column = columnRows[0];
160
+ if (column && column.dataType !== "text" && column.dataType !== "character varying") {
161
+ throw new Error(`Cannot migrate Better Auth account issuer from database type ${column.dataType}.`);
162
+ }
163
+
164
+ if (column?.isNullable === "NO") {
165
+ const invalidRows = await client.query<MigrationRow>(`
166
+ SELECT COUNT(*)::int AS "count"
167
+ FROM "account"
168
+ WHERE btrim("issuer") = ''
169
+ `);
170
+ if (readCount(invalidRows, "blank issuer") > 0) {
171
+ throw new Error("Cannot continue: the Better Auth account table contains blank issuer values.");
172
+ }
173
+ await client.query(legacyAccountIssuerTriggerSql());
174
+ await client.query("COMMIT");
175
+ transactionOpen = false;
176
+ return "current";
177
+ }
178
+
179
+ if (!column) {
180
+ await client.query('ALTER TABLE "account" ADD COLUMN "issuer" text');
181
+ }
182
+
183
+ const providerRows = await client.query<{ providerId: string }>(`
184
+ SELECT DISTINCT "providerId"
185
+ FROM "account"
186
+ WHERE "issuer" IS NULL
187
+ ORDER BY "providerId"
188
+ `);
189
+ for (const { providerId } of providerRows) {
190
+ const issuer = legacyAccountIssuer(providerId);
191
+ if (!issuer) {
192
+ throw new Error(
193
+ `Cannot infer a trusted Better Auth issuer for legacy provider ${providerId}. ` +
194
+ "Follow the Better Auth 1.7 account identity migration guide and provide a verified mapping.",
195
+ );
196
+ }
197
+
198
+ if (providerId === "credential") {
199
+ const mismatchRows = await client.query<MigrationRow>(`
200
+ SELECT COUNT(*)::int AS "count"
201
+ FROM "account"
202
+ WHERE "issuer" IS NULL
203
+ AND "providerId" = 'credential'
204
+ AND "accountId" <> "userId"
205
+ `);
206
+ if (readCount(mismatchRows, "credential identity mismatch") > 0) {
207
+ throw new Error(
208
+ "Cannot migrate credential accounts whose accountId differs from the linked userId.",
209
+ );
210
+ }
211
+ }
212
+
213
+ await client.query(
214
+ `UPDATE "account"
215
+ SET "issuer" = $1,
216
+ "accountId" = CASE WHEN "providerId" = 'credential' THEN "userId" ELSE "accountId" END
217
+ WHERE "issuer" IS NULL AND "providerId" = $2`,
218
+ [issuer, providerId],
219
+ );
220
+ }
221
+
222
+ const invalidRows = await client.query<MigrationRow>(`
223
+ SELECT COUNT(*)::int AS "count"
224
+ FROM "account"
225
+ WHERE "issuer" IS NULL OR btrim("issuer") = ''
226
+ `);
227
+ if (readCount(invalidRows, "unresolved issuer") > 0) {
228
+ throw new Error("Cannot continue: one or more Better Auth accounts have no trusted issuer.");
229
+ }
230
+
231
+ const collisionRows = await client.query<MigrationRow>(`
232
+ SELECT COUNT(*)::int AS "count"
233
+ FROM (
234
+ SELECT "issuer", "accountId"
235
+ FROM "account"
236
+ GROUP BY "issuer", "accountId"
237
+ HAVING COUNT(*) > 1
238
+ ) AS collisions
239
+ `);
240
+ if (readCount(collisionRows, "identity collision") > 0) {
241
+ throw new Error(
242
+ "Cannot continue: Better Auth accounts collide on the new issuer and accountId identity key.",
243
+ );
244
+ }
245
+
246
+ await client.query(legacyAccountIssuerTriggerSql());
247
+ await client.query('ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL');
248
+ await client.query(
249
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "account_issuer_accountId_uidx" ON "account" ("issuer", "accountId")',
250
+ );
251
+ await client.query("COMMIT");
252
+ transactionOpen = false;
253
+ return "migrated";
254
+ } catch (error: unknown) {
255
+ if (transactionOpen) await client.query("ROLLBACK");
256
+ throw error;
257
+ } finally {
258
+ client.release();
259
+ }
260
+ }
@@ -1,6 +1,6 @@
1
1
  import { createConfigStore } from "@podosoft/podokit-auth";
2
- import { pool } from "./db";
2
+ import { readAuthConfig } from "./db";
3
3
 
4
4
  // One process-wide store keeps the auth runtime and capabilities endpoint on the
5
5
  // same cache snapshot. Admin writes invalidate this store before rebuilding auth.
6
- export const authConfigStore = createConfigStore(pool);
6
+ export const authConfigStore = createConfigStore({ list: readAuthConfig });
@@ -20,6 +20,8 @@ import { getAuth, authRuntime, primeAuth } from "./auth-provider";
20
20
  import { generateAuthOpenApiDocument } from "./auth.openapi";
21
21
  import { ROLE_NAMES } from "./permissions";
22
22
  import { AuthService } from "./auth.service";
23
+ import { configureAuthQueryDatabase } from "./db";
24
+ import { setMailConfigStore } from "../mail/mailer";
23
25
 
24
26
  export const AUTH = Symbol("auth") as ServiceKey<AuthService>;
25
27
  export const SETTINGS = Symbol("settings") as ServiceKey<SettingsService>;
@@ -146,6 +148,8 @@ export const authModule: PodokitModule = {
146
148
  name: "auth",
147
149
  configure: (_env, services): void => {
148
150
  const database = services.resolve(DATABASE);
151
+ configureAuthQueryDatabase(database.sql);
152
+ setMailConfigStore(authConfigStore);
149
153
  const accessPolicy = services.resolve(ACCESS_POLICY);
150
154
  const settings = new SettingsService(database.sql);
151
155
  const authService = new AuthService(settings, accessPolicy);
@@ -2,7 +2,7 @@ import { AppException } from "@podosoft/podokit-contracts";
2
2
  import type { AccessPolicy } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import { getAuth, refreshIfStale } from "./auth-provider";
5
- import { pool } from "./db";
5
+ import { closeAuthDatabase } from "./db";
6
6
 
7
7
  export interface AuthUser extends Record<string, unknown> {
8
8
  id: string;
@@ -73,6 +73,6 @@ export class AuthService {
73
73
  }
74
74
 
75
75
  async close(): Promise<void> {
76
- await pool.end();
76
+ await closeAuthDatabase();
77
77
  }
78
78
  }
@@ -5,7 +5,7 @@ import { actionEmail, sendMail } from "../mail/mailer";
5
5
  import { sendSms } from "../sms/sms";
6
6
  import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
7
7
  import { orgAc, orgRoles } from "./org-permissions";
8
- import { pool } from "./db";
8
+ import { authDatabase, readAppSettings } from "./db";
9
9
  import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
10
10
  import { apiKey } from "@better-auth/api-key";
11
11
  import { passkey } from "@better-auth/passkey";
@@ -46,7 +46,7 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
46
46
  // server-enforced toggles) apply without a restart. Everything below the injection
47
47
  // markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
48
48
  export function buildAuth(config: AuthConfig) {
49
- const isSignupOpen = createSignupOpenCheck(pool);
49
+ const isSignupOpen = createSignupOpenCheck(readAppSettings);
50
50
  const plugins: BetterAuthPlugin[] = [
51
51
  twoFactor(),
52
52
  magicLink({
@@ -176,7 +176,7 @@ export function buildAuth(config: AuthConfig) {
176
176
  // real server boundary (disabled features 404). Other PodoKit modules add their
177
177
  // own hooks below the marker.
178
178
  const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
179
- before: createFeatureGate(pool, isSignupOpen),
179
+ before: createFeatureGate(readAppSettings, isSignupOpen),
180
180
  };
181
181
  // podokit:begin:auth-hooks
182
182
  // podokit:end:auth-hooks
@@ -184,7 +184,7 @@ export function buildAuth(config: AuthConfig) {
184
184
  const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
185
185
 
186
186
  return betterAuth({
187
- database: pool,
187
+ database: authDatabase,
188
188
  emailAndPassword: {
189
189
  enabled: true,
190
190
  // Pending email/password registrations should complete without creating a
@@ -1,12 +1,57 @@
1
+ import { Database as SqliteDatabase } from "bun:sqlite";
2
+ import type { SQL } from "bun";
1
3
  import { Pool } from "pg";
4
+ import type { AuthConfigRow } from "@podosoft/podokit-auth";
5
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
+ import { validateEnv } from "../config/env.validation";
7
+ import { PROVIDERS } from "../config/providers";
8
+ import { databaseUrl, sqliteDatabasePath } from "../database/database";
2
9
 
3
- // One shared pg connection pool, in its own module so it can be imported by
4
- // auth.ts, the mailer, and the config store without an import cycle (auth.ts
5
- // imports the mailer, which needs the pool for runtime SMTP config).
6
- export const pool = new Pool({
7
- host: process.env.POSTGRES_HOST ?? "localhost",
8
- port: Number(process.env.POSTGRES_PORT ?? 5432),
9
- user: process.env.POSTGRES_USER ?? "podokit",
10
- password: process.env.POSTGRES_PASSWORD ?? "podokit",
11
- database: process.env.POSTGRES_DB ?? "podokit",
12
- });
10
+ const env = validateEnv(process.env);
11
+ const url = databaseUrl(env);
12
+ function databaseProvider(): DatabaseProviderName {
13
+ return PROVIDERS.database;
14
+ }
15
+ const provider = databaseProvider();
16
+
17
+ export const postgresPool = provider === "postgres"
18
+ ? new Pool({ connectionString: url })
19
+ : null;
20
+
21
+ export const sqliteDatabase = provider === "sqlite"
22
+ ? new SqliteDatabase(sqliteDatabasePath(url), { create: true, strict: true })
23
+ : null;
24
+
25
+ export const authDatabase = postgresPool ?? sqliteDatabase;
26
+ if (!authDatabase) throw new Error("No authentication database is configured");
27
+
28
+ let queryDatabase: SQL | undefined;
29
+
30
+ export function configureAuthQueryDatabase(sql: SQL): void {
31
+ queryDatabase = sql;
32
+ }
33
+
34
+ function queries(): SQL {
35
+ if (!queryDatabase) throw new Error("Authentication query database is not configured");
36
+ return queryDatabase;
37
+ }
38
+
39
+ export async function readAppSettings(): Promise<Array<{ key: string; value: string }>> {
40
+ const sql = queries();
41
+ return sql<Array<{ key: string; value: string }>>`
42
+ SELECT "key", "value" FROM "app_setting"
43
+ `;
44
+ }
45
+
46
+ export async function readAuthConfig(): Promise<AuthConfigRow[]> {
47
+ const sql = queries();
48
+ const rows = await sql<Array<Omit<AuthConfigRow, "enabled"> & { enabled: boolean | number }>>`
49
+ SELECT "key", "enabled", "config", "secret", "updatedAt" FROM "auth_config"
50
+ `;
51
+ return rows.map((row) => ({ ...row, enabled: row.enabled === true || row.enabled === 1 }));
52
+ }
53
+
54
+ export async function closeAuthDatabase(): Promise<void> {
55
+ if (postgresPool) await postgresPool.end();
56
+ sqliteDatabase?.close();
57
+ }
@@ -1,5 +1,4 @@
1
1
  import { APIError, createAuthMiddleware } from "better-auth/api";
2
- import type { Pool } from "pg";
3
2
  import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
4
3
 
5
4
  export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
@@ -22,22 +21,20 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
22
21
  const CACHE_TTL_MS = 3_000;
23
22
 
24
23
  export type SignupOpenCheck = () => Promise<boolean>;
24
+ export type AppSettingsReader = () => Promise<Array<{ key: string; value: string }>>;
25
25
 
26
26
  /** Public registration is the default until an administrator explicitly closes
27
27
  * it. The short cache keeps every auth flow on the same live site-setting
28
28
  * policy without rebuilding the Better Auth instance. */
29
- export function createSignupOpenCheck(pool: Pool): SignupOpenCheck {
29
+ export function createSignupOpenCheck(readSettings: AppSettingsReader): SignupOpenCheck {
30
30
  let signupOpen = true;
31
31
  let fetchedAt = 0;
32
32
 
33
33
  return async (): Promise<boolean> => {
34
34
  if (Date.now() - fetchedAt < CACHE_TTL_MS) return signupOpen;
35
35
  try {
36
- const res = await pool.query<{ value: string }>(
37
- 'SELECT "value" FROM "app_setting" WHERE "key" = $1',
38
- ["site.allowSignup"],
39
- );
40
- signupOpen = res.rows[0]?.value !== "false";
36
+ const rows = await readSettings();
37
+ signupOpen = rows.find((row) => row.key === "site.allowSignup")?.value !== "false";
41
38
  } catch {
42
39
  signupOpen = true;
43
40
  }
@@ -67,8 +64,8 @@ export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: str
67
64
  * (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
68
65
  * the shipped defaults apply. */
69
66
  export function createFeatureGate(
70
- pool: Pool,
71
- isSignupOpen: SignupOpenCheck = createSignupOpenCheck(pool),
67
+ readSettings: AppSettingsReader,
68
+ isSignupOpen: SignupOpenCheck = createSignupOpenCheck(readSettings),
72
69
  ) {
73
70
  let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
74
71
  let fetchedAt = 0;
@@ -76,12 +73,11 @@ export function createFeatureGate(
76
73
  async function flags(): Promise<Record<FeatureFlag, boolean>> {
77
74
  if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
78
75
  try {
79
- const res = await pool.query<{ key: string; value: string }>(
80
- 'SELECT "key", "value" FROM "app_setting" WHERE "key" = ANY($1)',
81
- [Object.keys(FLAG_DEFAULTS)],
82
- );
76
+ const rows = await readSettings();
83
77
  const next = { ...FLAG_DEFAULTS };
84
- for (const row of res.rows) next[row.key as FeatureFlag] = row.value === "true";
78
+ for (const row of rows) {
79
+ if (row.key in FLAG_DEFAULTS) next[row.key as FeatureFlag] = row.value === "true";
80
+ }
85
81
  cache = next;
86
82
  } catch {
87
83
  cache = { ...FLAG_DEFAULTS };
@@ -1,14 +1,31 @@
1
1
  import { getMigrations } from "better-auth/db/migration";
2
2
  import { auth } from "./auth/auth";
3
- import { pool } from "./auth/db";
4
- import dataSource from "./database/data-source";
3
+ import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
4
+ import {
5
+ migrateLegacyAccountIssuers,
6
+ postgresAccountIssuerMigrationDatabase,
7
+ } from "./auth/account-issuer-migration";
8
+ import { runSqliteMigrations } from "./database/sqlite-migrator";
5
9
 
6
10
  async function runMigrations(): Promise<void> {
11
+ if (postgresPool) {
12
+ await migrateLegacyAccountIssuers(postgresAccountIssuerMigrationDatabase(postgresPool));
13
+ }
14
+
7
15
  const authMigrations = await getMigrations(auth.options);
8
16
  await authMigrations.runMigrations();
9
17
 
10
- await dataSource.initialize();
11
- await dataSource.runMigrations();
18
+ if (sqliteDatabase) {
19
+ await runSqliteMigrations(sqliteDatabase);
20
+ } else {
21
+ const { appDataSource: dataSource } = await import("./database/data-source.js");
22
+ await dataSource.initialize();
23
+ try {
24
+ await dataSource.runMigrations();
25
+ } finally {
26
+ await dataSource.destroy();
27
+ }
28
+ }
12
29
  }
13
30
 
14
31
  runMigrations()
@@ -17,6 +34,5 @@ runMigrations()
17
34
  process.exitCode = 1;
18
35
  })
19
36
  .finally(async (): Promise<void> => {
20
- if (dataSource.isInitialized) await dataSource.destroy();
21
- await pool.end();
37
+ await closeAuthDatabase();
22
38
  });
@@ -4,11 +4,12 @@ export class InitAppSettings1720400000000 implements MigrationInterface {
4
4
  name = "InitAppSettings1720400000000";
5
5
 
6
6
  async up(queryRunner: QueryRunner): Promise<void> {
7
+ const sqlite = queryRunner.connection.options.type === "better-sqlite3";
7
8
  await queryRunner.query(`
8
9
  CREATE TABLE "app_setting" (
9
10
  "key" text NOT NULL,
10
11
  "value" text NOT NULL,
11
- "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
12
+ "updatedAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
12
13
  CONSTRAINT "PK_app_setting_key" PRIMARY KEY ("key")
13
14
  )
14
15
  `);
@@ -4,6 +4,7 @@ export class InitAuthConfig1720500000000 implements MigrationInterface {
4
4
  name = "InitAuthConfig1720500000000";
5
5
 
6
6
  async up(queryRunner: QueryRunner): Promise<void> {
7
+ const sqlite = queryRunner.connection.options.type === "better-sqlite3";
7
8
  // Admin-managed auth configuration (OAuth providers, SMTP, server-enforced
8
9
  // toggles). Secrets (OAuth client secret, SMTP password) are stored encrypted
9
10
  // in "secret" (AES-256-GCM envelope; key derived from BETTER_AUTH_SECRET —
@@ -12,10 +13,10 @@ export class InitAuthConfig1720500000000 implements MigrationInterface {
12
13
  await queryRunner.query(`
13
14
  CREATE TABLE "auth_config" (
14
15
  "key" text NOT NULL,
15
- "enabled" boolean NOT NULL DEFAULT false,
16
- "config" jsonb NOT NULL DEFAULT '{}',
16
+ "enabled" ${sqlite ? "integer" : "boolean"} NOT NULL DEFAULT ${sqlite ? "0" : "false"},
17
+ "config" ${sqlite ? "text" : "jsonb"} NOT NULL DEFAULT '{}',
17
18
  "secret" text,
18
- "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
19
+ "updatedAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
19
20
  CONSTRAINT "PK_auth_config_key" PRIMARY KEY ("key")
20
21
  )
21
22
  `);
@@ -3,16 +3,19 @@ import type { QueryRunner } from "typeorm";
3
3
  import { InitAppSettings1720400000000 } from "../migrations/1720400000000-InitAppSettings";
4
4
 
5
5
  describe("app setting migration", () => {
6
- it("seeds every authentication feature and policy default", async () => {
7
- const queries: string[] = [];
8
- const runner = {
6
+ function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
7
+ return {
8
+ connection: { options: { type } },
9
9
  query: (sql: string): Promise<unknown[]> => {
10
10
  queries.push(sql);
11
11
  return Promise.resolve([]);
12
12
  },
13
13
  } as unknown as QueryRunner;
14
+ }
14
15
 
15
- await new InitAppSettings1720400000000().up(runner);
16
+ it("seeds every authentication feature and policy default", async () => {
17
+ const queries: string[] = [];
18
+ await new InitAppSettings1720400000000().up(runner("postgres", queries));
16
19
 
17
20
  const seed = queries.join("\n");
18
21
  for (const key of [
@@ -31,4 +34,12 @@ describe("app setting migration", () => {
31
34
  expect(seed).toContain(`('${key}',`);
32
35
  }
33
36
  });
37
+
38
+ it("uses SQLite-compatible timestamp syntax", async () => {
39
+ const queries: string[] = [];
40
+ await new InitAppSettings1720400000000().up(runner("better-sqlite3", queries));
41
+ expect(queries[0]).toContain("datetime");
42
+ expect(queries[0]).toContain("CURRENT_TIMESTAMP");
43
+ expect(queries[0]).not.toContain("TIMESTAMP WITH TIME ZONE");
44
+ });
34
45
  });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { QueryRunner } from "typeorm";
3
+ import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
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("auth config migration", () => {
16
+ it("retains PostgreSQL boolean and JSONB columns", async () => {
17
+ const queries: string[] = [];
18
+ await new InitAuthConfig1720500000000().up(runner("postgres", queries));
19
+ expect(queries[0]).toContain("boolean NOT NULL DEFAULT false");
20
+ expect(queries[0]).toContain("jsonb NOT NULL");
21
+ });
22
+
23
+ it("uses SQLite integer booleans and JSON text", async () => {
24
+ const queries: string[] = [];
25
+ await new InitAuthConfig1720500000000().up(runner("better-sqlite3", queries));
26
+ expect(queries[0]).toContain("integer NOT NULL DEFAULT 0");
27
+ expect(queries[0]).toContain("text NOT NULL DEFAULT '{}'");
28
+ expect(queries[0]).toContain("DEFAULT CURRENT_TIMESTAMP");
29
+ });
30
+ });
@@ -7,7 +7,7 @@
7
7
  ".claude/skills/podokit-configure-auth/**"
8
8
  ],
9
9
  "dependencies": {
10
- "@podosoft/podokit-auth": "^0.3.0",
10
+ "@podosoft/podokit-auth": "^0.4.0",
11
11
  "@podosoft/podokit-contracts": "^0.4.0",
12
12
  "better-auth": "^1.7.1",
13
13
  "pg": "^8.12.0",
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { externalJobId, parseExternalJobId } from "./bullmq-job.queue";
3
+
4
+ describe("BullMQ job identifiers", () => {
5
+ it("round-trips queue names without exposing an ambiguous id", () => {
6
+ const id = externalJobId("email/delivery", "42");
7
+ expect(parseExternalJobId(id)).toEqual({ name: "email/delivery", id: "42" });
8
+ expect(parseExternalJobId("invalid")).toBeNull();
9
+ });
10
+ });