@podosoft/podokit 1.0.2 → 1.1.4

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 (119) hide show
  1. package/README.md +74 -6
  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/base/AGENTS.md +9 -3
  23. package/dist/templates/base/README.md +12 -8
  24. package/dist/templates/fullstack/AGENTS.md +24 -5
  25. package/dist/templates/fullstack/README.md +75 -11
  26. package/dist/templates/fullstack/apps/api/package.json +4 -3
  27. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  28. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  29. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  30. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  31. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  32. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  33. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  35. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  36. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  37. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  38. package/dist/templates/fullstack/dot-env.example +2 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  48. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  49. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  58. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
  59. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  60. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  61. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  62. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  63. package/dist/templates/modules/auth/module.manifest.json +6 -6
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  65. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  67. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  68. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  69. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  70. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  71. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  73. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  75. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  76. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  77. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  78. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  79. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  81. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  83. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  85. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  87. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  88. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  89. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  90. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  91. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  92. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  93. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  94. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  95. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  96. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  97. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  98. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  99. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  100. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  101. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  102. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  103. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  104. package/dist/templates/modules/redis/module.manifest.json +2 -0
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  106. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  108. package/dist/templates/modules/sse/module.manifest.json +4 -6
  109. package/dist/templates/todo/AGENTS.md +25 -6
  110. package/dist/templates/todo/README.md +72 -14
  111. package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
  112. package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
  113. package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
  114. package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
  115. package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
  116. package/package.json +2 -1
  117. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  118. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  119. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -0,0 +1,123 @@
1
+ import type { Database as SqliteDatabase } from "bun:sqlite";
2
+ import type { Pool } from "pg";
3
+
4
+ type MigrationRow = Record<string, unknown>;
5
+
6
+ export interface AccountIssuerMigrationClient {
7
+ query<Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]>;
8
+ release(): void;
9
+ }
10
+
11
+ export interface AccountIssuerMigrationDatabase {
12
+ connect(): Promise<AccountIssuerMigrationClient>;
13
+ }
14
+
15
+ export type AccountIssuerMigrationResult = "absent" | "current" | "migrated";
16
+
17
+ function readCount(rows: readonly MigrationRow[]): number {
18
+ const value = rows[0]?.count;
19
+ if (typeof value === "number" && Number.isSafeInteger(value)) return value;
20
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
21
+ throw new Error("Cannot read account identity collision count");
22
+ }
23
+
24
+ function assertNoCollisions(count: number): void {
25
+ if (count > 0) {
26
+ throw new Error("Cannot upgrade Better Auth: duplicate providerId and accountId identities require manual resolution");
27
+ }
28
+ }
29
+
30
+ export function postgresAccountIssuerMigrationDatabase(pool: Pool): AccountIssuerMigrationDatabase {
31
+ return {
32
+ connect: async (): Promise<AccountIssuerMigrationClient> => {
33
+ const client = await pool.connect();
34
+ return {
35
+ query: async <Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]> => {
36
+ const result = await client.query<Row>(sql, values === undefined ? undefined : [...values]);
37
+ return result.rows;
38
+ },
39
+ release: (): void => client.release(),
40
+ };
41
+ },
42
+ };
43
+ }
44
+
45
+ /** Relax the obsolete 1.7.0-1.7.2 issuer schema for Better Auth 1.7.3 and later. */
46
+ export async function migrateLegacyAccountIssuers(
47
+ database: AccountIssuerMigrationDatabase,
48
+ ): Promise<AccountIssuerMigrationResult> {
49
+ const client = await database.connect();
50
+ let transactionOpen = false;
51
+ try {
52
+ await client.query("BEGIN");
53
+ transactionOpen = true;
54
+ await client.query("SELECT pg_advisory_xact_lock(1886350955, 1)");
55
+ const tableRows = await client.query<{ exists: boolean }>(`
56
+ SELECT to_regclass('"account"') IS NOT NULL AS "exists"
57
+ `);
58
+ if (tableRows[0]?.exists !== true) {
59
+ await client.query("COMMIT");
60
+ transactionOpen = false;
61
+ return "absent";
62
+ }
63
+ await client.query('LOCK TABLE "account" IN SHARE ROW EXCLUSIVE MODE');
64
+ const collisions = await client.query<MigrationRow>(`
65
+ SELECT COUNT(*)::int AS "count" FROM (
66
+ SELECT "providerId", "accountId" FROM "account"
67
+ GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
68
+ ) AS collisions
69
+ `);
70
+ assertNoCollisions(readCount(collisions));
71
+ const columns = await client.query<{ exists: boolean }>(`
72
+ SELECT EXISTS (
73
+ SELECT 1 FROM pg_attribute
74
+ WHERE attrelid = '"account"'::regclass AND attname = 'issuer' AND NOT attisdropped
75
+ ) AS "exists"
76
+ `);
77
+ await client.query('DROP TRIGGER IF EXISTS podokit_fill_legacy_account_issuer ON "account"');
78
+ await client.query('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
79
+ if (columns[0]?.exists === true) {
80
+ await client.query('ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL');
81
+ }
82
+ await client.query('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
83
+ await client.query("COMMIT");
84
+ transactionOpen = false;
85
+ return columns[0]?.exists === true ? "migrated" : "current";
86
+ } catch (error: unknown) {
87
+ if (transactionOpen) await client.query("ROLLBACK");
88
+ throw error;
89
+ } finally {
90
+ client.release();
91
+ }
92
+ }
93
+
94
+ /** SQLite follows the upstream cleanup: remove the unused issuer column. */
95
+ export function migrateSqliteLegacyAccountIssuers(database: SqliteDatabase): AccountIssuerMigrationResult {
96
+ database.run("BEGIN IMMEDIATE");
97
+ try {
98
+ const table = database.query<{ name: string }, []>(
99
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'account'",
100
+ ).get();
101
+ if (!table) {
102
+ database.run("COMMIT");
103
+ return "absent";
104
+ }
105
+ const collision = database.query<{ count: number }, []>(`
106
+ SELECT COUNT(*) AS count FROM (
107
+ SELECT "providerId", "accountId" FROM "account"
108
+ GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
109
+ )
110
+ `).get();
111
+ assertNoCollisions(readCount(collision ? [collision] : []));
112
+ const hasIssuer = database.query<{ name: string }, []>('PRAGMA table_info("account")').all()
113
+ .some((column) => column.name === "issuer");
114
+ database.run('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
115
+ if (hasIssuer) database.run('ALTER TABLE "account" DROP COLUMN "issuer"');
116
+ database.run('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
117
+ database.run("COMMIT");
118
+ return hasIssuer ? "migrated" : "current";
119
+ } catch (error: unknown) {
120
+ database.run("ROLLBACK");
121
+ throw error;
122
+ }
123
+ }
@@ -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,33 @@
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
+ migrateSqliteLegacyAccountIssuers,
7
+ postgresAccountIssuerMigrationDatabase,
8
+ } from "./auth/account-issuer-migration";
9
+ import { runSqliteMigrations } from "./database/sqlite-migrator";
5
10
 
6
11
  async function runMigrations(): Promise<void> {
12
+ if (postgresPool) {
13
+ await migrateLegacyAccountIssuers(postgresAccountIssuerMigrationDatabase(postgresPool));
14
+ }
15
+ if (sqliteDatabase) migrateSqliteLegacyAccountIssuers(sqliteDatabase);
16
+
7
17
  const authMigrations = await getMigrations(auth.options);
8
18
  await authMigrations.runMigrations();
9
19
 
10
- await dataSource.initialize();
11
- await dataSource.runMigrations();
20
+ if (sqliteDatabase) {
21
+ await runSqliteMigrations(sqliteDatabase);
22
+ } else {
23
+ const { appDataSource: dataSource } = await import("./database/data-source.js");
24
+ await dataSource.initialize();
25
+ try {
26
+ await dataSource.runMigrations();
27
+ } finally {
28
+ await dataSource.destroy();
29
+ }
30
+ }
12
31
  }
13
32
 
14
33
  runMigrations()
@@ -17,6 +36,5 @@ runMigrations()
17
36
  process.exitCode = 1;
18
37
  })
19
38
  .finally(async (): Promise<void> => {
20
- if (dataSource.isInitialized) await dataSource.destroy();
21
- await pool.end();
39
+ await closeAuthDatabase();
22
40
  });
@@ -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,13 +7,13 @@
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
- "better-auth": "^1.7.1",
12
+ "better-auth": "^1.7.7",
13
13
  "pg": "^8.12.0",
14
- "@better-auth/api-key": "^1.7.1",
15
- "@better-auth/passkey": "^1.7.1",
16
- "@better-auth/oauth-provider": "^1.7.1"
14
+ "@better-auth/api-key": "^1.7.7",
15
+ "@better-auth/passkey": "^1.7.7",
16
+ "@better-auth/oauth-provider": "^1.7.7"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/pg": "^8.11.10"
@@ -58,7 +58,7 @@
58
58
  "file": "AGENTS.md",
59
59
  "marker": "<!-- podokit:end:agents-modules -->",
60
60
  "optional": true,
61
- "text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n"
61
+ "text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n- Back up existing databases before upgrading authentication. Migrations check duplicate `(providerId, accountId)` identities and clean up obsolete issuer constraints; resolve reported collisions manually rather than guessing account ownership.\n"
62
62
  }
63
63
  ],
64
64
  "instructions": [
@@ -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
+ });
@@ -0,0 +1,107 @@
1
+ import { Queue, Worker } from "bullmq";
2
+ import type {
3
+ EnqueueOptions,
4
+ JobHandler,
5
+ JobQueue,
6
+ JobReference,
7
+ JobSnapshot,
8
+ JobStatus,
9
+ Unsubscribe,
10
+ } from "@podosoft/podokit-runtime";
11
+ import { redisConnection } from "./queue";
12
+
13
+ const ID_SEPARATOR = "~";
14
+
15
+ export function externalJobId(name: string, id: string): string {
16
+ return `${encodeURIComponent(name)}${ID_SEPARATOR}${id}`;
17
+ }
18
+
19
+ export function parseExternalJobId(id: string): { name: string; id: string } | null {
20
+ const separator = id.indexOf(ID_SEPARATOR);
21
+ if (separator < 1 || separator === id.length - 1) return null;
22
+ try {
23
+ return { name: decodeURIComponent(id.slice(0, separator)), id: id.slice(separator + 1) };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function statusOf(state: string): JobStatus {
30
+ if (state === "active" || state === "completed" || state === "failed") return state;
31
+ return "waiting";
32
+ }
33
+
34
+ export class BullMqJobQueue implements JobQueue {
35
+ private readonly queues = new Map<string, Queue>();
36
+ private readonly workers = new Map<string, Worker>();
37
+
38
+ async enqueue(
39
+ name: string,
40
+ payload: unknown,
41
+ options: EnqueueOptions = {},
42
+ ): Promise<JobReference> {
43
+ const queue = this.queue(name);
44
+ const job = await queue.add(name, payload, {
45
+ ...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
46
+ ...(options.attempts === undefined ? {} : { attempts: options.attempts }),
47
+ ...(options.deduplicationKey === undefined
48
+ ? {}
49
+ : { deduplication: { id: options.deduplicationKey } }),
50
+ });
51
+ if (job.id === undefined) throw new Error("BullMQ did not assign a job id");
52
+ return { id: externalJobId(name, job.id), name };
53
+ }
54
+
55
+ async get(externalId: string): Promise<JobSnapshot | null> {
56
+ const parsed = parseExternalJobId(externalId);
57
+ if (!parsed) return null;
58
+ const job = await this.queue(parsed.name).getJob(parsed.id);
59
+ if (!job) return null;
60
+ return {
61
+ id: externalId,
62
+ name: parsed.name,
63
+ status: statusOf(await job.getState()),
64
+ attempts: job.attemptsMade,
65
+ createdAt: job.timestamp,
66
+ updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
67
+ ...(job.returnvalue === undefined ? {} : { result: job.returnvalue as unknown }),
68
+ ...(job.failedReason ? { error: job.failedReason } : {}),
69
+ };
70
+ }
71
+
72
+ process(name: string, handler: JobHandler): Promise<Unsubscribe> {
73
+ if (this.workers.has(name)) throw new Error(`A handler is already registered for job ${name}`);
74
+ const worker = new Worker(
75
+ name,
76
+ async (job): Promise<void> => {
77
+ await handler(job.data as unknown, {
78
+ id: externalJobId(name, String(job.id)),
79
+ name,
80
+ });
81
+ },
82
+ { connection: redisConnection() },
83
+ );
84
+ this.workers.set(name, worker);
85
+ return Promise.resolve(async () => {
86
+ this.workers.delete(name);
87
+ await worker.close();
88
+ });
89
+ }
90
+
91
+ async close(): Promise<void> {
92
+ await Promise.all([
93
+ ...[...this.workers.values()].map((worker) => worker.close()),
94
+ ...[...this.queues.values()].map((queue) => queue.close()),
95
+ ]);
96
+ this.workers.clear();
97
+ this.queues.clear();
98
+ }
99
+
100
+ private queue(name: string): Queue {
101
+ const current = this.queues.get(name);
102
+ if (current) return current;
103
+ const queue = new Queue(name, { connection: redisConnection() });
104
+ this.queues.set(name, queue);
105
+ return queue;
106
+ }
107
+ }