@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.
- package/README.md +19 -5
- package/dist/add.d.ts +14 -1
- package/dist/add.js +42 -5
- package/dist/create.d.ts +4 -0
- package/dist/create.js +9 -0
- package/dist/deploy-compose-profile.d.ts +3 -0
- package/dist/deploy-compose-profile.js +74 -8
- package/dist/deploy-compose-render.d.ts +1 -0
- package/dist/deploy-compose-render.js +46 -1
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.js +18 -8
- package/dist/deploy.js +157 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +8 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +22 -4
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +142 -0
- package/dist/remove.js +6 -3
- package/dist/templates/fullstack/AGENTS.md +5 -4
- package/dist/templates/fullstack/README.md +10 -3
- package/dist/templates/fullstack/apps/api/package.json +3 -2
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
- package/dist/templates/fullstack/compose.dev.yaml +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-env.example +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
- package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
- package/dist/templates/modules/bullmq/module.manifest.json +7 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
- package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
- package/dist/templates/modules/events-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
- package/dist/templates/modules/events-redis/module.manifest.json +32 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/file-upload/module.manifest.json +4 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
- package/dist/templates/modules/job-progress/module.manifest.json +7 -8
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
- package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
- package/dist/templates/modules/mailer/module.manifest.json +2 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
- package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
- package/dist/templates/modules/redis/module.manifest.json +2 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
- package/dist/templates/modules/sse/module.manifest.json +4 -6
- package/dist/templates/todo/AGENTS.md +1 -1
- package/dist/templates/todo/README.md +8 -3
- package/package.json +2 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
- package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
71
|
-
isSignupOpen: SignupOpenCheck = createSignupOpenCheck(
|
|
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
|
|
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
|
|
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 {
|
|
4
|
-
import
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
21
|
-
await pool.end();
|
|
37
|
+
await closeAuthDatabase();
|
|
22
38
|
});
|
package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts
CHANGED
|
@@ -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
|
`);
|
package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts
CHANGED
|
@@ -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
|
`);
|
package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|