@maudecode/kanvil 0.1.0 → 0.2.0

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 (56) hide show
  1. package/apps/server/dist/{auth-BscXb1NQ.mjs → auth-I1XEr0d4.mjs} +272 -7
  2. package/apps/server/dist/{config-Qxr_7oqZ.mjs → config-CbNqtJEj.mjs} +8 -2
  3. package/apps/server/dist/index.mjs +22 -186
  4. package/apps/server/dist/migrate.mjs +1 -1
  5. package/apps/server/dist/recover.mjs +21 -6
  6. package/apps/server/drizzle/0013_authentication_policy.sql +2 -0
  7. package/apps/server/drizzle/meta/0013_snapshot.json +4240 -0
  8. package/apps/server/drizzle/meta/_journal.json +7 -0
  9. package/apps/web/dist/assets/{CompositeItem-D9KvUNiq.js → CompositeItem-BWPJ5Mol.js} +1 -1
  10. package/apps/web/dist/assets/admin-settings-DZqkC6ju.js +1 -0
  11. package/apps/web/dist/assets/{date-picker-lhXxKIlN.js → date-picker-6H_iwP6t.js} +1 -1
  12. package/apps/web/dist/assets/{ellipsis-CN74O_EP.js → ellipsis-BT172Uvu.js} +1 -1
  13. package/apps/web/dist/assets/{forms-456UbfIC.js → forms-B4Vg3CPz.js} +23 -23
  14. package/apps/web/dist/assets/image-edit-trigger-DGmVtWLC.js +1 -0
  15. package/apps/web/dist/assets/index-C_a21taR.js +50 -0
  16. package/apps/web/dist/assets/index-DM-D_3Sv.css +2 -0
  17. package/apps/web/dist/assets/kbd-tTQGZxaK.js +1 -0
  18. package/apps/web/dist/assets/link-DoIP9KJ6.js +2 -0
  19. package/apps/web/dist/assets/{list-order-1UoOT5nR.js → list-order-Bzsrihyr.js} +1 -1
  20. package/apps/web/dist/assets/member-directory-BiCE8DRq.js +1 -0
  21. package/apps/web/dist/assets/popover-BhzZ0U1d.js +1 -0
  22. package/apps/web/dist/assets/project-label-provider-9L7Uts3_.js +1 -0
  23. package/apps/web/dist/assets/project-settings-Q7Q9Reec.js +1 -0
  24. package/apps/web/dist/assets/{rotate-ccw-BLeWpuu1.js → rotate-ccw-sjyLwLLy.js} +1 -1
  25. package/apps/web/dist/assets/settings-B6qD6q1J.js +1 -0
  26. package/apps/web/dist/assets/settings-layout-B-AwUIMu.js +1 -0
  27. package/apps/web/dist/assets/switch-D0nFHj6l.js +1 -0
  28. package/apps/web/dist/assets/{table-BQbZG4BK.js → table-CYZiwo9g.js} +1 -1
  29. package/apps/web/dist/assets/ticket-detail-B-3vbHSB.js +2 -0
  30. package/apps/web/dist/assets/ticket-editor-C3d8TZIT.js +1 -0
  31. package/apps/web/dist/assets/ticket-list-LtGGOnwN.js +5 -0
  32. package/apps/web/dist/assets/ticket-tags-2rdmkcJV.js +1 -0
  33. package/apps/web/dist/assets/{upload-5Wg5ou_Z.js → upload-DXPRzAaa.js} +1 -1
  34. package/apps/web/dist/assets/useNavigate-DFohfUD3.js +1 -0
  35. package/apps/web/dist/assets/workspace-settings-D3tCm1vH.js +1 -0
  36. package/apps/web/dist/index.html +8 -4
  37. package/docs/api.md +4 -0
  38. package/docs/mobile.md +0 -2
  39. package/docs/releasing.md +3 -1
  40. package/docs/self-hosting.md +9 -1
  41. package/package.json +2 -1
  42. package/apps/web/dist/assets/admin-settings-B0u2-aAK.js +0 -1
  43. package/apps/web/dist/assets/camera-Cv5wKkKn.js +0 -1
  44. package/apps/web/dist/assets/index-CVzq9BMC.css +0 -2
  45. package/apps/web/dist/assets/index-Dt8wYIVA.js +0 -51
  46. package/apps/web/dist/assets/instance-BCNvOvYa.js +0 -1
  47. package/apps/web/dist/assets/member-directory-C81ugis6.js +0 -1
  48. package/apps/web/dist/assets/popover-BWk65ByY.js +0 -1
  49. package/apps/web/dist/assets/project-settings-DnpVfVf5.js +0 -1
  50. package/apps/web/dist/assets/settings-DUXuOgWn.js +0 -1
  51. package/apps/web/dist/assets/settings-layout-xR4hrfte.js +0 -1
  52. package/apps/web/dist/assets/ticket-detail-B9M_eaVu.js +0 -2
  53. package/apps/web/dist/assets/ticket-editor-Chg1lDuU.js +0 -1
  54. package/apps/web/dist/assets/ticket-list-C8500tXj.js +0 -5
  55. package/apps/web/dist/assets/ticket-tags-cbwQ93Gt.js +0 -1
  56. package/apps/web/dist/assets/workspace-settings-Dte44Kkx.js +0 -1
@@ -1,12 +1,12 @@
1
- import { a as photoInputSchema, c as searchAuthentication, d as account, f as apikey, h as user, i as instanceUpdateSchema, l as searchSettingsSchema, m as session, n as adminInstanceSchema, o as photoResultSchema, r as brokerSettingsSchema, s as s3SettingsSchema, t as readConfig, u as createDatabase } from "./config-Qxr_7oqZ.mjs";
1
+ import { a as instanceUpdateSchema, c as s3SettingsSchema, d as createDatabase, f as account, g as user, h as session, i as brokerSettingsSchema, n as adminInstanceSchema, o as photoInputSchema, p as apikey, s as photoResultSchema, t as readConfig, u as searchSettingsSchema } from "./config-CbNqtJEj.mjs";
2
2
  import { t as migrateDatabase } from "./migrate-C9f4ozB4.mjs";
3
- import { A as storageBackends, B as notificationPreferencesInput, C as members, D as projects, E as profilePhotos, F as ApiError, G as roles, H as okSchema, I as errorSchema, K as scopes, L as hasAdminRole, M as tickets, N as workspaceMembers, O as pushDeliveries, P as workspaces, R as isOperation, S as instanceSettings, T as notifications, U as operations, V as notificationPreferencesSchema, W as pushDeviceInput, _ as attachments, a as json, b as eventClock, c as projectRoleSQL, d as createBlobStorage, f as decodeImage, g as attachmentContent, h as createPollingJob, i as fail, j as subscriptions, k as pushDevices, l as createTicketSearch, m as createObjectStore, n as assertVersion, o as one, p as imageResponse, r as createDomain, s as requireRow, t as createAuth, u as createTicketIndex, v as blobObjects, w as notificationPreferences, x as events, y as deliveryCheckpoints, z as memberSchema } from "./auth-BscXb1NQ.mjs";
3
+ import { A as pushDevices, B as memberSchema, D as profilePhotos, E as notifications, F as workspaces, G as pushDeviceInput, H as notificationPreferencesSchema, I as ApiError, J as matchesOIDCBinding, K as roles, L as errorSchema, M as subscriptions, N as tickets, O as projects, P as workspaceMembers, R as hasAdminRole, S as events, T as notificationPreferences, U as okSchema, V as notificationPreferencesInput, W as operations, _ as attachmentContent, a as fail, b as deliveryCheckpoints, c as requireRow, d as createTicketIndex, f as createBlobStorage, g as createPollingJob, h as createObjectStore, i as createDomain, j as storageBackends, k as pushDeliveries, l as projectRoleSQL, m as imageResponse, n as createInstance, o as json, p as decodeImage, q as scopes, r as assertVersion, s as one, t as createAuth, u as createTicketSearch, v as attachments, w as members, x as eventClock, y as blobObjects, z as isOperation } from "./auth-I1XEr0d4.mjs";
4
4
  import { serve } from "@hono/node-server";
5
5
  import pg from "pg";
6
6
  import { and, asc, count, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
7
7
  import { z } from "zod";
8
8
  import { fileURLToPath } from "node:url";
9
- import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { createHash, timingSafeEqual } from "node:crypto";
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { connect } from "amqplib";
@@ -88,7 +88,7 @@ function authPath(request) {
88
88
  return fail(400, "invalid_path", "The authentication path is invalid");
89
89
  }
90
90
  }
91
- function createAccountAdministration(database, auth) {
91
+ function createAccountAdministration(database, auth, config) {
92
92
  async function revokeAfterPasswordChange(headers, userId) {
93
93
  try {
94
94
  await auth.api.revokeUserSessions({
@@ -99,16 +99,23 @@ function createAccountAdministration(database, auth) {
99
99
  fail(503, "session_revocation_failed", "The password changed, but existing sessions could not be revoked. Revoke this user’s sessions before continuing.");
100
100
  }
101
101
  }
102
- async function mutate(request, run) {
102
+ async function mutate(request, run, administratorOnly = true) {
103
103
  const login = await auth.api.getSession({ headers: request.headers });
104
104
  if (!login) fail(401, "unauthorized", "Sign in to continue");
105
- if (!hasAdminRole(login.user.role) || login.user.banned) fail(403, "forbidden", "Instance administrator access is required");
105
+ if (administratorOnly && !hasAdminRole(login.user.role) || login.user.banned) fail(403, "forbidden", "Instance administrator access is required");
106
106
  const lock = await database.pool.connect();
107
107
  let locked = false;
108
108
  try {
109
109
  await lock.query("SELECT pg_advisory_lock(7361083)");
110
110
  locked = true;
111
111
  const path = authPath(request);
112
+ if (path === "/api/auth/unlink-account") {
113
+ if (!one((await lock.query("SELECT email_password_enabled AS enabled FROM instance_settings WHERE id=1")).rows).enabled) {
114
+ const accountId = z.string().parse(mutationBody.parse(await request.clone().json()).accountId);
115
+ const issuer = (await auth.$context).socialProviders.find((provider) => provider.id === "oidc")?.issuer;
116
+ if (!(await lock.query(`SELECT account_id AS "accountId", id_token AS "idToken" FROM account WHERE user_id=$1 AND provider_id='oidc' AND id<>$2`, [login.user.id, accountId])).rows.some((binding) => matchesOIDCBinding(binding, issuer, config.OIDC_CLIENT_ID))) fail(409, "oidc_binding_required", "Enable password sign-in before unlinking your last account for the configured OIDC provider");
117
+ }
118
+ }
112
119
  let passwordUserId;
113
120
  if (path.endsWith("/admin/set-user-password")) passwordUserId = mutationBody.parse(await request.clone().json()).userId;
114
121
  if ([
@@ -152,7 +159,8 @@ function createAccountAdministration(database, auth) {
152
159
  "remove-user",
153
160
  "set-user-password"
154
161
  ].some((name) => path === `/api/auth/admin/${name}`);
155
- return request.method === "POST" && guarded ? mutate(request, () => auth.handler(request)) : auth.handler(request);
162
+ const unlink = path === "/api/auth/unlink-account";
163
+ return request.method === "POST" && (guarded || unlink) ? mutate(request, () => auth.handler(request), !unlink) : auth.handler(request);
156
164
  },
157
165
  register(app) {
158
166
  app.openAPIRegistry.registerPath({
@@ -241,181 +249,6 @@ function createAccountAdministration(database, auth) {
241
249
  };
242
250
  }
243
251
  //#endregion
244
- //#region src/instance.ts
245
- function storageIdentity(settings) {
246
- return createHash("sha256").update(JSON.stringify([
247
- settings.endpoint,
248
- settings.region,
249
- settings.bucket,
250
- settings.prefix,
251
- settings.forcePathStyle
252
- ])).digest("hex");
253
- }
254
- function createInstance(database, config) {
255
- const key = createHash("sha256").update("kanvil:instance-config:v1:").update(config.BETTER_AUTH_SECRET).digest();
256
- function seal(value) {
257
- const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv);
258
- const bytes = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
259
- return Buffer.concat([
260
- iv,
261
- cipher.getAuthTag(),
262
- bytes
263
- ]).toString("base64");
264
- }
265
- function unseal(value) {
266
- try {
267
- const bytes = Buffer.from(value, "base64"), decipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12));
268
- decipher.setAuthTag(bytes.subarray(12, 28));
269
- return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString());
270
- } catch {
271
- return fail(503, "configuration_key_changed", "Saved integration credentials cannot be decrypted. Restore the instance secret from your backup.");
272
- }
273
- }
274
- async function read(db = database.db) {
275
- await db.insert(instanceSettings).values({ id: 1 }).onConflictDoNothing();
276
- return one(await db.select().from(instanceSettings).where(eq(instanceSettings.id, 1)));
277
- }
278
- async function backend(id, db = database.db) {
279
- const row = one(await db.select().from(storageBackends).where(eq(storageBackends.id, id)));
280
- const settings = s3SettingsSchema.parse(unseal(row.config));
281
- return {
282
- ...row,
283
- settings,
284
- spec: {
285
- provider: "s3",
286
- config: settings
287
- }
288
- };
289
- }
290
- async function resolveS3(settings, db = database.db) {
291
- settings = s3SettingsSchema.strip().parse(settings);
292
- const identity = storageIdentity(settings);
293
- const previous = (await db.select().from(storageBackends).where(eq(storageBackends.identity, identity)))[0];
294
- const old = previous ? s3SettingsSchema.parse(unseal(previous.config)) : void 0;
295
- const { secretAccessKey: suppliedSecret, ...publicSettings } = settings, secret = suppliedSecret || (old?.accessKeyId === settings.accessKeyId ? old.secretAccessKey : void 0);
296
- if (settings.accessKeyId && !secret) fail(400, "missing_secret", "Provide the S3 secret access key");
297
- return {
298
- ...publicSettings,
299
- ...secret ? { secretAccessKey: secret } : {}
300
- };
301
- }
302
- async function resolveSearch(settings, db = database.db) {
303
- settings = searchSettingsSchema.strip().parse(settings);
304
- const row = await read(db), old = row.searchConfig ? searchSettingsSchema.parse(unseal(row.searchConfig)) : null, same = old?.url === settings.url && old.index === settings.index;
305
- const { apiKey: suppliedKey, password: suppliedPassword, auth: requestedAuth, ...publicSettings } = settings, auth = requestedAuth ?? (suppliedKey ? "apiKey" : suppliedPassword && settings.username ? "basic" : same && old ? searchAuthentication(old) : searchAuthentication(settings)), apiKey = suppliedKey || (same ? old?.apiKey : void 0), password = suppliedPassword || (same && old?.username === settings.username ? old?.password : void 0);
306
- if (auth === "apiKey" && !apiKey) fail(400, "missing_secret", "Provide the search API key");
307
- if (auth === "basic" && (!settings.username || !password)) fail(400, "missing_secret", "Provide the search username and password");
308
- return {
309
- ...publicSettings,
310
- auth,
311
- ...auth === "apiKey" && apiKey ? { apiKey } : {},
312
- ...auth === "basic" && password ? { password } : {}
313
- };
314
- }
315
- async function saveBackend(settings, db = database.db) {
316
- const complete = await resolveS3(settings, db), identity = storageIdentity(settings);
317
- return one(await db.insert(storageBackends).values({
318
- identity,
319
- config: seal(complete)
320
- }).onConflictDoUpdate({
321
- target: storageBackends.identity,
322
- set: { config: seal(complete) }
323
- }).returning());
324
- }
325
- async function storage() {
326
- if (config.KANVIL_STORAGE_PROVIDER === "local") return null;
327
- if (config.KANVIL_STORAGE_PROVIDER === "s3") return saveBackend(s3SettingsSchema.parse({
328
- endpoint: config.S3_ENDPOINT ?? "",
329
- region: config.S3_REGION,
330
- bucket: config.S3_BUCKET,
331
- prefix: config.S3_PREFIX,
332
- forcePathStyle: config.S3_FORCE_PATH_STYLE,
333
- accessKeyId: config.S3_ACCESS_KEY_ID ?? "",
334
- secretAccessKey: config.S3_SECRET_ACCESS_KEY
335
- }));
336
- const row = await read();
337
- return row.storageBackendId ? backend(row.storageBackendId) : null;
338
- }
339
- async function search() {
340
- const row = await read();
341
- if (config.KANVIL_SEARCH_PROVIDER === "postgres") return null;
342
- if (config.KANVIL_SEARCH_PROVIDER === "elasticsearch") {
343
- const settings = searchSettingsSchema.parse({
344
- url: config.ELASTICSEARCH_URL,
345
- index: config.ELASTICSEARCH_INDEX ?? `kanvil-${row.identity}`,
346
- auth: config.ELASTICSEARCH_AUTH,
347
- apiKey: config.ELASTICSEARCH_API_KEY,
348
- username: config.ELASTICSEARCH_USERNAME,
349
- password: config.ELASTICSEARCH_PASSWORD
350
- });
351
- return {
352
- ...settings,
353
- auth: searchAuthentication(settings)
354
- };
355
- }
356
- const settings = row.searchConfig ? searchSettingsSchema.parse(unseal(row.searchConfig)) : null;
357
- return settings ? {
358
- ...settings,
359
- auth: searchAuthentication(settings)
360
- } : null;
361
- }
362
- async function broker() {
363
- if (config.KANVIL_BROKER_PROVIDER === "postgres") return null;
364
- if (config.KANVIL_BROKER_PROVIDER === "rabbitmq") return brokerSettingsSchema.parse({
365
- url: config.RABBITMQ_URL,
366
- exchange: config.RABBITMQ_EXCHANGE
367
- });
368
- const row = await read();
369
- return row.brokerConfig ? brokerSettingsSchema.parse(unseal(row.brokerConfig)) : null;
370
- }
371
- async function resolveBroker(settings, db = database.db) {
372
- settings = brokerSettingsSchema.strip().parse(settings);
373
- const row = await read(db), old = row.brokerConfig ? brokerSettingsSchema.parse(unseal(row.brokerConfig)) : null;
374
- const url = settings.url || old?.url;
375
- if (!url) return fail(400, "missing_secret", "Provide the RabbitMQ connection URL");
376
- return {
377
- ...settings,
378
- url
379
- };
380
- }
381
- async function update(value) {
382
- const input = instanceUpdateSchema.parse(value);
383
- return database.db.transaction(async (tx) => {
384
- await tx.execute(sql`SELECT pg_advisory_xact_lock(7361083)`);
385
- const current = await read(tx);
386
- assertVersion(current, input.expectedVersion, "instance settings", "saving");
387
- let storageId = current.storageBackendId, searchConfig = current.searchConfig, brokerConfig = current.brokerConfig;
388
- if (!config.KANVIL_STORAGE_PROVIDER) storageId = input.storage.provider === "s3" ? (await saveBackend(input.storage, tx)).id : null;
389
- if (!config.KANVIL_SEARCH_PROVIDER) {
390
- if (input.search.provider === "postgres") searchConfig = null;
391
- else searchConfig = seal(await resolveSearch(input.search, tx));
392
- }
393
- if (!config.KANVIL_BROKER_PROVIDER && input.broker) brokerConfig = input.broker.provider === "postgres" ? null : seal(await resolveBroker(input.broker, tx));
394
- await tx.update(instanceSettings).set({
395
- name: input.name,
396
- description: input.description,
397
- storageBackendId: storageId,
398
- searchConfig,
399
- brokerConfig,
400
- version: current.version + 1
401
- }).where(eq(instanceSettings.id, 1));
402
- });
403
- }
404
- return {
405
- read,
406
- storage,
407
- search,
408
- backend,
409
- saveBackend,
410
- resolveS3,
411
- resolveSearch,
412
- broker,
413
- resolveBroker,
414
- update,
415
- config
416
- };
417
- }
418
- //#endregion
419
252
  //#region src/access.ts
420
253
  const credentialPermissions = z.object({ kanvil: z.array(z.enum(scopes)) });
421
254
  const credentialMetadata = z.object({ projectIds: z.array(z.uuid()).max(1e3) });
@@ -815,7 +648,7 @@ async function createEventDelivery(config, instance, database, factory = createW
815
648
  }
816
649
  //#endregion
817
650
  //#region src/instance-routes.ts
818
- function registerInstanceRoutes(app, database, instance, blobs, search, delivery) {
651
+ function registerInstanceRoutes(app, database, instance, blobs, search, delivery, auth) {
819
652
  app.openAPIRegistry.registerComponent("securitySchemes", "sessionCookie", {
820
653
  type: "apiKey",
821
654
  in: "cookie",
@@ -922,6 +755,7 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
922
755
  return Response.json({
923
756
  id: row.identity,
924
757
  version: row.version,
758
+ authentication: await instance.authentication(),
925
759
  name: row.name,
926
760
  description: row.description,
927
761
  storage: {
@@ -995,7 +829,8 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
995
829
  const candidate = await instance.resolveBroker(input.broker);
996
830
  if (!isDeepStrictEqual(candidate, await instance.broker())) await delivery.test(candidate);
997
831
  }
998
- await instance.update(input);
832
+ const issuer = input.authentication && !input.authentication.emailPasswordEnabled ? (await auth.$context).socialProviders.find((provider) => provider.id === "oidc")?.issuer : void 0;
833
+ await instance.update(input, c.get("actor").id, issuer);
999
834
  return Response.json({ ok: true });
1000
835
  });
1001
836
  app.post("/api/v1/admin/storage/test", async (c) => {
@@ -1269,7 +1104,7 @@ async function createApplication(database, config, overrides = {}) {
1269
1104
  search.sync().catch(() => {});
1270
1105
  });
1271
1106
  const push = adapters.push(database, config);
1272
- const accounts = createAccountAdministration(integrationDatabase, auth);
1107
+ const accounts = createAccountAdministration(integrationDatabase, auth, config);
1273
1108
  const app = new OpenAPIHono({ defaultHook: (result) => {
1274
1109
  if (!result.success) return Response.json({ error: {
1275
1110
  code: "validation_error",
@@ -1338,6 +1173,7 @@ async function createApplication(database, config, overrides = {}) {
1338
1173
  description: row.description
1339
1174
  })),
1340
1175
  version: "0.1.0",
1176
+ authentication: await instance.authentication(),
1341
1177
  setupRequired: await setupRequired(),
1342
1178
  push: config.KANVIL_PUSH_ENABLED,
1343
1179
  oidc: config.OIDC_DISCOVERY_URL ? {
@@ -1390,7 +1226,7 @@ async function createApplication(database, config, overrides = {}) {
1390
1226
  requireInstanceAdmin(c.get("actor"));
1391
1227
  await next();
1392
1228
  });
1393
- registerInstanceRoutes(app, database, instance, blobs, search, realtime);
1229
+ registerInstanceRoutes(app, database, instance, blobs, search, realtime, auth);
1394
1230
  accounts.register(app);
1395
1231
  app.openAPIRegistry.registerPath({
1396
1232
  method: "get",
@@ -1,4 +1,4 @@
1
- import { t as readConfig, u as createDatabase } from "./config-Qxr_7oqZ.mjs";
1
+ import { d as createDatabase, t as readConfig } from "./config-CbNqtJEj.mjs";
2
2
  import { t as migrateDatabase } from "./migrate-C9f4ozB4.mjs";
3
3
  //#region src/cli/migrate.ts
4
4
  const database = createDatabase(readConfig().DATABASE_URL);
@@ -1,19 +1,27 @@
1
- import { d as account, h as user, m as session, t as readConfig, u as createDatabase } from "./config-Qxr_7oqZ.mjs";
2
- import { i as fail, t as createAuth } from "./auth-BscXb1NQ.mjs";
3
- import { and, eq } from "drizzle-orm";
1
+ import { d as createDatabase, f as account, g as user, h as session, t as readConfig } from "./config-CbNqtJEj.mjs";
2
+ import { C as instanceSettings, R as hasAdminRole, a as fail, t as createAuth } from "./auth-I1XEr0d4.mjs";
3
+ import { and, eq, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
  import { parseArgs } from "node:util";
6
6
  import passwordPrompt from "@inquirer/password";
7
7
  import { readFileSync } from "node:fs";
8
8
  //#region src/recover-account.ts
9
9
  const localPasswordSchema = z.string().min(12).max(128);
10
- async function recoverLocalPassword(database, config, email, password) {
10
+ async function recoverLocalPassword(database, config, email, password, enablePasswordSignIn = false) {
11
11
  const normalizedEmail = z.email().parse(email.trim().toLowerCase());
12
12
  localPasswordSchema.parse(password);
13
13
  const hash = await (await createAuth(database, config).$context).password.hash(password);
14
14
  return database.db.transaction(async (tx) => {
15
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361083)`);
15
16
  const identity = (await tx.select().from(user).where(eq(user.email, normalizedEmail)).for("update"))[0];
16
17
  if (!identity) fail(404, "not_found", "No account has this email address");
18
+ if (enablePasswordSignIn) {
19
+ if (!hasAdminRole(identity.role) || identity.banned) fail(403, "forbidden", "Recover an active administrator to enable password sign-in");
20
+ await tx.update(instanceSettings).set({
21
+ emailPasswordEnabled: true,
22
+ version: sql`${instanceSettings.version} + 1`
23
+ }).where(eq(instanceSettings.id, 1));
24
+ }
17
25
  const where = and(eq(account.userId, identity.id), eq(account.providerId, "credential"));
18
26
  if ((await tx.select().from(account).where(where))[0]) await tx.update(account).set({
19
27
  password: hash,
@@ -33,7 +41,13 @@ async function recoverLocalPassword(database, config, email, password) {
33
41
  }
34
42
  //#endregion
35
43
  //#region src/cli/recover.ts
36
- const { values } = parseArgs({ options: { email: { type: "string" } } });
44
+ const { values } = parseArgs({ options: {
45
+ email: { type: "string" },
46
+ "enable-password-sign-in": {
47
+ type: "boolean",
48
+ default: false
49
+ }
50
+ } });
37
51
  if (!values.email) throw new Error("Pass --email <account-email>. The new password is read privately from the terminal or stdin.");
38
52
  const password = process.stdin.isTTY ? await passwordPrompt({
39
53
  message: "New local password",
@@ -43,7 +57,8 @@ const password = process.stdin.isTTY ? await passwordPrompt({
43
57
  const config = readConfig();
44
58
  const database = createDatabase(config.DATABASE_URL);
45
59
  try {
46
- await recoverLocalPassword(database, config, values.email, password);
60
+ await recoverLocalPassword(database, config, values.email, password, values["enable-password-sign-in"]);
61
+ if (values["enable-password-sign-in"]) console.info("Instance password sign-in enabled.");
47
62
  console.info("Local password replaced and account sessions revoked. API credentials and linked OIDC accounts are retained.");
48
63
  } finally {
49
64
  await database.pool.end();
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "instance_settings" ADD COLUMN "email_password_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "instance_settings" ADD COLUMN "sso_auto_provision" boolean DEFAULT false NOT NULL;