@naisys/erp 3.0.0-beta.37 → 3.0.0-beta.39

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.
@@ -282,7 +282,7 @@ export const UserScalarFieldEnum = {
282
282
  uuid: 'uuid',
283
283
  username: 'username',
284
284
  passwordHash: 'passwordHash',
285
- apiKey: 'apiKey',
285
+ apiKeyHash: 'apiKeyHash',
286
286
  isAgent: 'isAgent',
287
287
  createdAt: 'createdAt',
288
288
  updatedAt: 'updatedAt',
@@ -38,7 +38,7 @@ export default function authRoutes(fastify) {
38
38
  }
39
39
  // Standalone mode: authenticate against local DB
40
40
  const user = await erpDb.user.findUnique({ where: { username } });
41
- if (!user) {
41
+ if (!user || user.passwordHash === null) {
42
42
  return unauthorized(reply, "Invalid username or password");
43
43
  }
44
44
  const valid = await bcrypt.compare(password, user.passwordHash);
@@ -2,7 +2,8 @@ import { ErpPermissionEnum, GrantPermissionSchema } from "@naisys/erp-shared";
2
2
  import { z } from "zod/v4";
3
3
  import { authCache, requirePermission } from "../auth-middleware.js";
4
4
  import { mutationResult } from "../route-helpers.js";
5
- import { getUserById, getUserByUsername, grantPermission, revokePermission, rotateUserApiKey, } from "../services/user-service.js";
5
+ import { getUserById, getUserByUsername, grantPermission, hasUserApiKey, revokePermission, rotateUserApiKey, } from "../services/user-service.js";
6
+ import { isSupervisorAuth } from "../supervisorAuth.js";
6
7
  import { formatUser } from "./users.js";
7
8
  export default function userPermissionRoutes(fastify) {
8
9
  const app = fastify.withTypeProvider();
@@ -17,14 +18,25 @@ export default function userPermissionRoutes(fastify) {
17
18
  params: usernameParams,
18
19
  },
19
20
  }, async (request, reply) => {
21
+ if (isSupervisorAuth()) {
22
+ reply.code(400);
23
+ return {
24
+ success: false,
25
+ message: "API keys are managed by the supervisor when SSO is enabled.",
26
+ };
27
+ }
20
28
  const targetUser = await getUserByUsername(request.params.username);
21
29
  if (!targetUser) {
22
30
  reply.code(404);
23
31
  return { success: false, message: "User not found" };
24
32
  }
25
- await rotateUserApiKey(targetUser.id);
33
+ const apiKey = await rotateUserApiKey(targetUser.id);
26
34
  authCache.clear();
27
- return { success: true, message: "API key rotated" };
35
+ return {
36
+ success: true,
37
+ message: "API key generated. Copy it now; it cannot be shown again.",
38
+ apiKey,
39
+ };
28
40
  });
29
41
  // GRANT PERMISSION
30
42
  app.post("/:username/permissions", {
@@ -45,7 +57,8 @@ export default function userPermissionRoutes(fastify) {
45
57
  await grantPermission(targetUser.id, request.body.permission, request.erpUser.id);
46
58
  authCache.clear();
47
59
  const user = await getUserById(targetUser.id);
48
- const full = formatUser(user, request.erpUser.id, request.erpUser.permissions);
60
+ const hasApiKey = user ? await hasUserApiKey(user.id) : false;
61
+ const full = formatUser(user, request.erpUser.id, request.erpUser.permissions, { hasApiKey });
49
62
  return mutationResult(request, reply, full, {
50
63
  _actions: full._actions,
51
64
  });
@@ -91,7 +104,8 @@ export default function userPermissionRoutes(fastify) {
91
104
  await revokePermission(targetUser.id, permission);
92
105
  authCache.clear();
93
106
  const user = await getUserById(targetUser.id);
94
- const full = formatUser(user, request.erpUser.id, request.erpUser.permissions);
107
+ const hasApiKey = user ? await hasUserApiKey(user.id) : false;
108
+ const full = formatUser(user, request.erpUser.id, request.erpUser.permissions, { hasApiKey });
95
109
  return mutationResult(request, reply, full, {
96
110
  _actions: full._actions,
97
111
  });
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
4
4
  import { authCache, hasPermission, requirePermission, } from "../auth-middleware.js";
5
5
  import { API_PREFIX, collectionLink, paginationLinks, schemaLink, selfLink, } from "../hateoas.js";
6
6
  import { mutationResult } from "../route-helpers.js";
7
- import { createUserForAgent, createUserWithPassword, deleteUser, getUserApiKey, getUserByUsername, getUserByUuid, listUsers, updateUser, } from "../services/user-service.js";
7
+ import { createUserForAgent, createUserWithPassword, deleteUser, getUserByUsername, getUserByUuid, hasUserApiKey, listUsers, updateUser, } from "../services/user-service.js";
8
8
  import { isSupervisorAuth } from "../supervisorAuth.js";
9
9
  function userItemLinks(username) {
10
10
  return [
@@ -26,7 +26,10 @@ function userActions(username, isSelf, isAdmin) {
26
26
  body: { username: "" },
27
27
  });
28
28
  }
29
- if (isSelf) {
29
+ // In SSO mode the supervisor owns passwords (passkey-only) and external API
30
+ // keys, so ERP doesn't expose its own change-password / rotate-key actions.
31
+ const sso = isSupervisorAuth();
32
+ if (isSelf && !sso) {
30
33
  actions.push({
31
34
  rel: "change-password",
32
35
  href: `${API_PREFIX}/users/me/password`,
@@ -45,12 +48,14 @@ function userActions(username, isSelf, isAdmin) {
45
48
  schema: `${API_PREFIX}/schemas/GrantPermission`,
46
49
  body: { permission: "" },
47
50
  });
48
- actions.push({
49
- rel: "rotate-key",
50
- href: `${href}/rotate-key`,
51
- method: "POST",
52
- title: "Rotate API Key",
53
- });
51
+ if (!sso) {
52
+ actions.push({
53
+ rel: "rotate-key",
54
+ href: `${href}/rotate-key`,
55
+ method: "POST",
56
+ title: "Generate API Key",
57
+ });
58
+ }
54
59
  if (!isSelf) {
55
60
  actions.push({
56
61
  rel: "delete",
@@ -88,7 +93,7 @@ export function formatUser(user, currentUserId, currentUserPermissions, options)
88
93
  isAgent: user.isAgent,
89
94
  createdAt: user.createdAt.toISOString(),
90
95
  updatedAt: user.updatedAt.toISOString(),
91
- apiKey: isAdmin ? (options?.apiKey ?? null) : undefined,
96
+ hasApiKey: options?.hasApiKey ?? false,
92
97
  permissions: user.permissions.map((p) => ({
93
98
  permission: p.permission,
94
99
  grantedAt: p.grantedAt.toISOString(),
@@ -201,6 +206,13 @@ export default function userRoutes(fastify) {
201
206
  });
202
207
  return;
203
208
  }
209
+ if (isSupervisorAuth()) {
210
+ reply.code(400);
211
+ return {
212
+ success: false,
213
+ message: "Passwords are managed by the supervisor when SSO is enabled.",
214
+ };
215
+ }
204
216
  await updateUser(request.erpUser.id, {
205
217
  password: request.body.password,
206
218
  });
@@ -223,7 +235,6 @@ export default function userRoutes(fastify) {
223
235
  return mutationResult(request, reply, full, {
224
236
  id: full.id,
225
237
  username: full.username,
226
- apiKey: full.apiKey,
227
238
  _links: full._links,
228
239
  _actions: full._actions,
229
240
  });
@@ -288,7 +299,6 @@ export default function userRoutes(fastify) {
288
299
  return mutationResult(request, reply, full, {
289
300
  id: full.id,
290
301
  username: full.username,
291
- apiKey: full.apiKey,
292
302
  _links: full._links,
293
303
  _actions: full._actions,
294
304
  });
@@ -319,8 +329,10 @@ export default function userRoutes(fastify) {
319
329
  reply.code(404);
320
330
  return { success: false, message: "User not found" };
321
331
  }
322
- const apiKey = await getUserApiKey(user.id);
323
- return formatUser(user, request.erpUser.id, request.erpUser.permissions, { apiKey });
332
+ const hasApiKey = isSupervisorAuth()
333
+ ? false
334
+ : await hasUserApiKey(user.id);
335
+ return formatUser(user, request.erpUser.id, request.erpUser.permissions, { hasApiKey });
324
336
  });
325
337
  // UPDATE USER (admin can update any field; non-admin can only change own password)
326
338
  app.put("/:username", {
@@ -338,12 +350,21 @@ export default function userRoutes(fastify) {
338
350
  return { success: false, message: "User not found" };
339
351
  }
340
352
  const isAdmin = hasPermission(request.erpUser, "erp_admin");
341
- // Non-admins can only change their own password
342
- const body = isAdmin ? request.body : { password: request.body.password };
353
+ // In SSO mode the supervisor owns passwords, so we strip any password
354
+ // field before forwarding the update even from admins.
355
+ const sso = isSupervisorAuth();
356
+ const body = isAdmin
357
+ ? sso
358
+ ? { username: request.body.username }
359
+ : request.body
360
+ : sso
361
+ ? {}
362
+ : { password: request.body.password };
343
363
  try {
344
364
  const user = await updateUser(targetUser.id, body);
345
365
  authCache.clear();
346
- const full = formatUser(user, request.erpUser.id, request.erpUser.permissions);
366
+ const hasApiKey = sso ? false : await hasUserApiKey(user.id);
367
+ const full = formatUser(user, request.erpUser.id, request.erpUser.permissions, { hasApiKey });
347
368
  return mutationResult(request, reply, full, {
348
369
  _actions: full._actions,
349
370
  });
@@ -1,8 +1,7 @@
1
- import { getAgentApiKeyByUuid, rotateAgentApiKeyByUuid, } from "@naisys/hub-database";
1
+ import { generatePersistentUserApiKey } from "@naisys/common-node";
2
2
  import bcrypt from "bcryptjs";
3
- import { randomBytes, randomUUID } from "crypto";
3
+ import { randomUUID } from "crypto";
4
4
  import erpDb from "../erpDb.js";
5
- import { isSupervisorAuth } from "../supervisorAuth.js";
6
5
  // --- Prisma include & result type ---
7
6
  export const includePermissions = {
8
7
  permissions: true,
@@ -37,19 +36,12 @@ export async function getUserById(id) {
37
36
  include: includePermissions,
38
37
  });
39
38
  }
40
- export async function getUserApiKey(id) {
39
+ export async function hasUserApiKey(id) {
41
40
  const user = await erpDb.user.findUnique({
42
41
  where: { id },
43
- select: { isAgent: true, uuid: true, apiKey: true },
42
+ select: { apiKeyHash: true },
44
43
  });
45
- if (!user)
46
- return null;
47
- if (user.isAgent && isSupervisorAuth()) {
48
- return getAgentApiKeyByUuid(user.uuid);
49
- }
50
- else {
51
- return user.apiKey ?? null;
52
- }
44
+ return !!user?.apiKeyHash;
53
45
  }
54
46
  // --- Mutations ---
55
47
  export async function getUserByUuid(uuid) {
@@ -63,7 +55,6 @@ export async function createUserForAgent(username, uuid) {
63
55
  data: {
64
56
  username,
65
57
  uuid,
66
- passwordHash: "",
67
58
  isAgent: true,
68
59
  },
69
60
  include: includePermissions,
@@ -78,7 +69,6 @@ export async function createUserWithPassword(data) {
78
69
  uuid,
79
70
  passwordHash,
80
71
  isAgent: false,
81
- apiKey: randomBytes(32).toString("hex"),
82
72
  },
83
73
  include: includePermissions,
84
74
  });
@@ -111,22 +101,15 @@ export async function revokePermission(userId, permission) {
111
101
  });
112
102
  }
113
103
  export async function rotateUserApiKey(id) {
114
- const newKey = randomBytes(32).toString("hex");
115
- const user = await erpDb.user.findUnique({
116
- where: { id },
117
- select: { isAgent: true, uuid: true },
104
+ return generatePersistentUserApiKey(id, {
105
+ userExists: async (userId) => (await erpDb.user.findUnique({
106
+ where: { id: userId },
107
+ select: { id: true },
108
+ })) !== null,
109
+ updateApiKeyHash: (userId, apiKeyHash) => erpDb.user.update({
110
+ where: { id: userId },
111
+ data: { apiKeyHash },
112
+ }),
118
113
  });
119
- if (!user)
120
- throw new Error("User not found");
121
- if (user.isAgent && isSupervisorAuth()) {
122
- await rotateAgentApiKeyByUuid(user.uuid, newKey);
123
- }
124
- else {
125
- await erpDb.user.update({
126
- where: { id },
127
- data: { apiKey: newKey },
128
- });
129
- }
130
- return newKey;
131
114
  }
132
115
  //# sourceMappingURL=user-service.js.map
@@ -1,7 +1,7 @@
1
1
  import { SUPER_ADMIN_USERNAME } from "@naisys/common";
2
2
  import { ensureSuperAdmin } from "@naisys/supervisor-database";
3
3
  import bcrypt from "bcryptjs";
4
- import { randomBytes, randomUUID } from "crypto";
4
+ import { randomUUID } from "crypto";
5
5
  import erpDb from "./erpDb.js";
6
6
  const SALT_ROUNDS = 10;
7
7
  /**
@@ -31,7 +31,6 @@ export async function ensureLocalSuperAdmin(password) {
31
31
  uuid: randomUUID(),
32
32
  username: SUPER_ADMIN_USERNAME,
33
33
  passwordHash: hash,
34
- apiKey: randomBytes(32).toString("hex"),
35
34
  },
36
35
  });
37
36
  await ensureErpAdminPermission(user.id);
@@ -50,8 +49,8 @@ export async function ensureLocalSuperAdmin(password) {
50
49
  }
51
50
  /**
52
51
  * Sync superadmin from supervisor into ERP DB and ensure permissions.
53
- * For supervisor auth mode. The supervisor uses passkey-only auth, so the
54
- * mirrored ERP row stores a sentinel passwordHash that can never match.
52
+ * For supervisor auth mode. Supervisor uses passkey-only auth the
53
+ * mirrored ERP row has no passwordHash.
55
54
  */
56
55
  export async function ensureSupervisorSuperAdmin() {
57
56
  const result = await ensureSuperAdmin();
@@ -60,12 +59,9 @@ export async function ensureSupervisorSuperAdmin() {
60
59
  create: {
61
60
  uuid: result.user.uuid,
62
61
  username: result.user.username,
63
- passwordHash: "!sso-passkey-only",
64
- apiKey: result.user.apiKey,
65
62
  },
66
63
  update: {
67
64
  username: result.user.username,
68
- apiKey: result.user.apiKey,
69
65
  },
70
66
  });
71
67
  const localSuperAdmin = await erpDb.user.findUnique({
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@naisys/erp",
3
- "version": "3.0.0-beta.37",
3
+ "version": "3.0.0-beta.39",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@naisys/erp",
9
- "version": "3.0.0-beta.37",
9
+ "version": "3.0.0-beta.39",
10
10
  "dependencies": {
11
11
  "@fastify/cookie": "^11.0.2",
12
12
  "@fastify/cors": "^11.2.0",
@@ -14,11 +14,11 @@
14
14
  "@fastify/rate-limit": "^10.3.0",
15
15
  "@fastify/static": "^9.0.0",
16
16
  "@fastify/swagger": "^9.7.0",
17
- "@naisys/common": "3.0.0-beta.37",
18
- "@naisys/common-node": "3.0.0-beta.37",
19
- "@naisys/erp-shared": "3.0.0-beta.37",
20
- "@naisys/hub-database": "3.0.0-beta.37",
21
- "@naisys/supervisor-database": "3.0.0-beta.37",
17
+ "@naisys/common": "3.0.0-beta.39",
18
+ "@naisys/common-node": "3.0.0-beta.39",
19
+ "@naisys/erp-shared": "3.0.0-beta.39",
20
+ "@naisys/hub-database": "3.0.0-beta.39",
21
+ "@naisys/supervisor-database": "3.0.0-beta.39",
22
22
  "@prisma/adapter-better-sqlite3": "^7.5.0",
23
23
  "@prisma/client": "^7.5.0",
24
24
  "@scalar/fastify-api-reference": "^1.48.7",
@@ -394,41 +394,41 @@
394
394
  }
395
395
  },
396
396
  "node_modules/@naisys/common": {
397
- "version": "3.0.0-beta.37",
398
- "resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.37.tgz",
399
- "integrity": "sha512-b0XfCadaPcfewmK9b649WD+ZGB86Uk9BjTrx/tLSgd5Nbx7L6NuzTqTnDFScdeCmtyPk4oUGAzATmXwKTM8bew==",
397
+ "version": "3.0.0-beta.39",
398
+ "resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.39.tgz",
399
+ "integrity": "sha512-H65s+TNhjKC+6piF9sP9LpydRL+K7IEyD6gUgXDekiQ1+6OV9xgxzZmeOgTAGs/Mk3fSs73OJ6JC4WPjBwVSdA==",
400
400
  "dependencies": {
401
401
  "semver": "^7.7.4",
402
402
  "zod": "^4.3.6"
403
403
  }
404
404
  },
405
405
  "node_modules/@naisys/common-node": {
406
- "version": "3.0.0-beta.37",
407
- "resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.37.tgz",
408
- "integrity": "sha512-V4yyA79G93OSqZ+6l5FWoMqnATE2It78VkGyziNTqcrv2CIf8UDGbds7hHV+bp/0VplNe6BrZ4P7quYvgyCeeQ==",
406
+ "version": "3.0.0-beta.39",
407
+ "resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.39.tgz",
408
+ "integrity": "sha512-UGQwcHx8hbeTHjYKYB0Y9BQouwip614bOxUXeo2U+552gUTjmdjc2T7ng14opVqtZmYYzTidLWQrblS8mNa+sw==",
409
409
  "dependencies": {
410
- "@naisys/common": "3.0.0-beta.37",
410
+ "@naisys/common": "3.0.0-beta.39",
411
411
  "better-sqlite3": "^12.6.2",
412
412
  "js-yaml": "^4.1.1",
413
413
  "pino": "^10.3.1"
414
414
  }
415
415
  },
416
416
  "node_modules/@naisys/erp-shared": {
417
- "version": "3.0.0-beta.37",
418
- "resolved": "https://registry.npmjs.org/@naisys/erp-shared/-/erp-shared-3.0.0-beta.37.tgz",
419
- "integrity": "sha512-CjHqdXX/kmrEROLdqiLK6kTIauK2InTiXfOjyhtTQRVrB3TPmhsnH4qtnFGCDu2G00o2l+D30YUGyFt0ZrQyVw==",
417
+ "version": "3.0.0-beta.39",
418
+ "resolved": "https://registry.npmjs.org/@naisys/erp-shared/-/erp-shared-3.0.0-beta.39.tgz",
419
+ "integrity": "sha512-crXCyTQwdw/iLeyqWDmFkT5NDAZHMc2WxIBaesMnnnH+05kgIbw5gt+heByQpa8EK0qFcm+oLMtM6IM+ywoi2A==",
420
420
  "dependencies": {
421
- "@naisys/common": "3.0.0-beta.37",
421
+ "@naisys/common": "3.0.0-beta.39",
422
422
  "zod": "^4.3.6"
423
423
  }
424
424
  },
425
425
  "node_modules/@naisys/hub-database": {
426
- "version": "3.0.0-beta.37",
427
- "resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.37.tgz",
428
- "integrity": "sha512-PI8Jj4niCc6agObLWpP+Am8nrNcwAAk77/OmZmUWq1ZiBW+m5C60PFdfBvzLEZX/6G/JOb0VvI9oLpzFD6VRKg==",
426
+ "version": "3.0.0-beta.39",
427
+ "resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.39.tgz",
428
+ "integrity": "sha512-rTKKwhRWGp/5LcBiFLurY3VYdk6a5YNiEj7nfGMWYGbTozTw1X6PlCoBT3UV6g/brFPKDIlsS9tMDAhI/ibuuw==",
429
429
  "dependencies": {
430
- "@naisys/common": "3.0.0-beta.37",
431
- "@naisys/common-node": "3.0.0-beta.37",
430
+ "@naisys/common": "3.0.0-beta.39",
431
+ "@naisys/common-node": "3.0.0-beta.39",
432
432
  "@prisma/adapter-better-sqlite3": "^7.5.0",
433
433
  "@prisma/client": "^7.5.0",
434
434
  "better-sqlite3": "^12.6.2",
@@ -436,12 +436,12 @@
436
436
  }
437
437
  },
438
438
  "node_modules/@naisys/supervisor-database": {
439
- "version": "3.0.0-beta.37",
440
- "resolved": "https://registry.npmjs.org/@naisys/supervisor-database/-/supervisor-database-3.0.0-beta.37.tgz",
441
- "integrity": "sha512-NSbw5kbBYZIBB7aRkuhMfaARuU8P61RZK1ME1AlEL2UB0DMmXoys1MD49wC2hafP+/MV0B6miLAOIjfy7BAR1g==",
439
+ "version": "3.0.0-beta.39",
440
+ "resolved": "https://registry.npmjs.org/@naisys/supervisor-database/-/supervisor-database-3.0.0-beta.39.tgz",
441
+ "integrity": "sha512-YcSXdVmpD2LiDbpkdGsQ6PZ4UP2iuZiEzlSXChSzqAAM3s5Rj49IqkJcmDK+LTJTwRxOMMdgRbDLsflgeoM2bg==",
442
442
  "dependencies": {
443
- "@naisys/common": "3.0.0-beta.37",
444
- "@naisys/common-node": "3.0.0-beta.37",
443
+ "@naisys/common": "3.0.0-beta.39",
444
+ "@naisys/common-node": "3.0.0-beta.39",
445
445
  "@prisma/adapter-better-sqlite3": "^7.5.0",
446
446
  "@prisma/client": "^7.5.0",
447
447
  "bcryptjs": "^3.0.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naisys/erp",
3
- "version": "3.0.0-beta.37",
3
+ "version": "3.0.0-beta.39",
4
4
  "description": "NAISYS ERP - Web UI for AI-driven order and work management",
5
5
  "type": "module",
6
6
  "main": "dist/erpServer.js",
@@ -46,11 +46,11 @@
46
46
  "@fastify/rate-limit": "^10.3.0",
47
47
  "@fastify/static": "^9.0.0",
48
48
  "@fastify/swagger": "^9.7.0",
49
- "@naisys/common": "3.0.0-beta.37",
50
- "@naisys/common-node": "3.0.0-beta.37",
51
- "@naisys/erp-shared": "3.0.0-beta.37",
52
- "@naisys/hub-database": "3.0.0-beta.37",
53
- "@naisys/supervisor-database": "3.0.0-beta.37",
49
+ "@naisys/common": "3.0.0-beta.39",
50
+ "@naisys/common-node": "3.0.0-beta.39",
51
+ "@naisys/erp-shared": "3.0.0-beta.39",
52
+ "@naisys/hub-database": "3.0.0-beta.39",
53
+ "@naisys/supervisor-database": "3.0.0-beta.39",
54
54
  "@prisma/adapter-better-sqlite3": "^7.5.0",
55
55
  "@prisma/client": "^7.5.0",
56
56
  "@scalar/fastify-api-reference": "^1.48.7",
@@ -0,0 +1,10 @@
1
+ -- Replace persisted plaintext user API keys with nullable hashes.
2
+ -- Existing plaintext keys are intentionally discarded.
3
+
4
+ DROP INDEX IF EXISTS "users_api_key_key";
5
+
6
+ ALTER TABLE "users" RENAME COLUMN "api_key" TO "api_key_hash";
7
+
8
+ UPDATE "users" SET "api_key_hash" = NULL;
9
+
10
+ CREATE UNIQUE INDEX "users_api_key_hash_key" ON "users"("api_key_hash");
@@ -0,0 +1,39 @@
1
+ -- Make users.password_hash nullable. NULL means the user is not
2
+ -- password-authable (passkey-only, API-key-only, or agent).
3
+ -- Existing sentinel values are converted to NULL.
4
+
5
+ PRAGMA foreign_keys=OFF;
6
+
7
+ CREATE TABLE "users_new" (
8
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
9
+ "uuid" TEXT NOT NULL,
10
+ "username" TEXT NOT NULL,
11
+ "password_hash" TEXT,
12
+ "is_agent" INTEGER NOT NULL DEFAULT 0,
13
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ "updated_at" DATETIME NOT NULL,
15
+ "deleted_at" DATETIME,
16
+ "api_key_hash" TEXT
17
+ );
18
+
19
+ INSERT INTO "users_new" ("id", "uuid", "username", "password_hash", "is_agent", "created_at", "updated_at", "deleted_at", "api_key_hash")
20
+ SELECT
21
+ "id",
22
+ "uuid",
23
+ "username",
24
+ CASE WHEN "password_hash" IN ('!sso-passkey-only', '!api-key-only', '') THEN NULL ELSE "password_hash" END,
25
+ "is_agent",
26
+ "created_at",
27
+ "updated_at",
28
+ "deleted_at",
29
+ "api_key_hash"
30
+ FROM "users";
31
+
32
+ DROP TABLE "users";
33
+ ALTER TABLE "users_new" RENAME TO "users";
34
+
35
+ CREATE UNIQUE INDEX "users_uuid_key" ON "users"("uuid");
36
+ CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
37
+ CREATE UNIQUE INDEX "users_api_key_hash_key" ON "users"("api_key_hash");
38
+
39
+ PRAGMA foreign_keys=ON;
@@ -418,8 +418,8 @@ model User {
418
418
  id Int @id @default(autoincrement())
419
419
  uuid String @unique
420
420
  username String @unique
421
- passwordHash String @map("password_hash")
422
- apiKey String? @unique @map("api_key")
421
+ passwordHash String? @map("password_hash")
422
+ apiKeyHash String? @unique @map("api_key_hash")
423
423
  isAgent Boolean @default(false) @map("is_agent")
424
424
  createdAt DateTime @default(now()) @map("created_at")
425
425
  updatedAt DateTime @updatedAt @map("updated_at")