From dfeb4d361eeb04def93c4a1bef35b47f627ed407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 15:41:42 +0200 Subject: [PATCH] fix(tests): align 20 drifted tests with current source behavior Tests fell behind source changes: lastTotpAt replay-attack prevention, activeSession invalidation on password reset, select clauses in permission updates, UNAUTHORIZED (anti-enumeration) for disabled TOTP, and password minimum raised from 8 to 12 characters. Also fix root eslint.config.mjs to ignore packages/ (linted via turbo) and add --no-warn-ignored to lint-staged to suppress warnings for ignored files. Co-Authored-By: Claude Opus 4.6 --- .lintstagedrc.json | 2 +- eslint.config.mjs | 1 + ...ls-user-admin-password-role-errors.test.ts | 26 +- ...ools-user-admin-user-create-errors.test.ts | 28 +- ...tools-user-self-service-mfa-enable.test.ts | 11 +- .../src/__tests__/auth-password-reset.test.ts | 34 +- .../api/src/__tests__/reset-password.test.ts | 26 +- .../src/__tests__/user-router-auth.test.ts | 318 +++++---- .../api/src/__tests__/user-router.test.ts | 80 ++- .../__tests__/user-self-service-mfa.test.ts | 78 ++- packages/api/src/router/assistant-tools.ts | 652 +++++++++--------- 11 files changed, 711 insertions(+), 545 deletions(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index a0c8bea..e741160 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "*.{ts,tsx}": ["eslint --fix", "prettier --write"], + "*.{ts,tsx}": ["eslint --fix --no-warn-ignored", "prettier --write"], "*.{json,md}": ["prettier --write"] } diff --git a/eslint.config.mjs b/eslint.config.mjs index dc1a03a..d454f22 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,7 @@ export default [ { ignores: [ "apps/**", + "packages/**", "node_modules/**", ".claude/**", "backups/**", diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts index 2f7040a..b742e34 100644 --- a/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts +++ b/packages/api/src/__tests__/assistant-tools-user-admin-password-role-errors.test.ts @@ -31,13 +31,15 @@ describe("assistant user admin password and role errors", () => { const result = await executeTool( "set_user_password", - JSON.stringify({ userId: "user_missing", password: "secret123" }), + JSON.stringify({ userId: "user_missing", password: "SecurePass123!" }), ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "User not found with the given criteria.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "User not found with the given criteria.", + }), + ); }); it("returns a stable error when resetting a password that is too short", async () => { @@ -56,9 +58,11 @@ describe("assistant user admin password and role errors", () => { ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "Password must be at least 8 characters.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "Password must be at least 12 characters.", + }), + ); expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); }); @@ -78,8 +82,10 @@ describe("assistant user admin password and role errors", () => { ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "User not found with the given criteria.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "User not found with the given criteria.", + }), + ); }); }); diff --git a/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts b/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts index a1badf8..6f9950f 100644 --- a/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts +++ b/packages/api/src/__tests__/assistant-tools-user-admin-user-create-errors.test.ts @@ -38,14 +38,16 @@ describe("assistant user admin tools user create errors", () => { JSON.stringify({ email: "peter.parker@example.com", name: "Peter Parker", - password: "secret123", + password: "SecurePass123!", }), ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "User with this email already exists.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "User with this email already exists.", + }), + ); }); it("returns a stable error when creating a user without a name", async () => { @@ -63,14 +65,16 @@ describe("assistant user admin tools user create errors", () => { JSON.stringify({ email: "miles.morales@example.com", name: "", - password: "secret123", + password: "SecurePass123!", }), ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "Name is required.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "Name is required.", + }), + ); expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); }); @@ -94,9 +98,11 @@ describe("assistant user admin tools user create errors", () => { ctx, ); - expect(JSON.parse(result.content)).toEqual(expect.objectContaining({ - error: "Password must be at least 8 characters.", - })); + expect(JSON.parse(result.content)).toEqual( + expect.objectContaining({ + error: "Password must be at least 12 characters.", + }), + ); expect(ctx.db.user.findUnique).not.toHaveBeenCalled(); }); }); diff --git a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts index 5158d0c..81d4cd5 100644 --- a/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts +++ b/packages/api/src/__tests__/assistant-tools-user-self-service-mfa-enable.test.ts @@ -66,11 +66,18 @@ describe("assistant user self-service MFA tools - enable flow", () => { expect(db.user.findUnique).toHaveBeenCalledWith({ where: { id: "user_1" }, - select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, + select: { + id: true, + name: true, + email: true, + totpSecret: true, + totpEnabled: true, + lastTotpAt: true, + }, }); expect(db.user.update).toHaveBeenCalledWith({ where: { id: "user_1" }, - data: { totpEnabled: true }, + data: { totpEnabled: true, lastTotpAt: expect.any(Date) }, }); expect(db.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ diff --git a/packages/api/src/__tests__/auth-password-reset.test.ts b/packages/api/src/__tests__/auth-password-reset.test.ts index 939d01b..080acd8 100644 --- a/packages/api/src/__tests__/auth-password-reset.test.ts +++ b/packages/api/src/__tests__/auth-password-reset.test.ts @@ -20,16 +20,21 @@ vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$n const FUTURE = new Date(Date.now() + 60 * 60 * 1000); const PAST = new Date(Date.now() - 1000); -function makeDb(overrides: { - user?: Partial>; - resetToken?: Partial>; -} = {}) { +function makeDb( + overrides: { + user?: Partial>; + resetToken?: Partial>; + } = {}, +) { return { user: { findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }), - update: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({ id: "user_1" }), ...overrides.user, }, + activeSession: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, passwordResetToken: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), create: vi.fn().mockResolvedValue({}), @@ -68,7 +73,10 @@ describe("auth.requestPasswordReset", () => { }), ); expect(sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ to: "user@example.com", subject: expect.stringContaining("reset") }), + expect.objectContaining({ + to: "user@example.com", + subject: expect.stringContaining("reset"), + }), ); }); @@ -92,7 +100,8 @@ describe("auth.requestPasswordReset", () => { await authRouter.createCaller(ctx).requestPasswordReset({ email: "user@example.com" }); // deleteMany called before create - const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock.invocationCallOrder[0]!; + const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock + .invocationCallOrder[0]!; const createOrder = vi.mocked(db.passwordResetToken.create).mock.invocationCallOrder[0]!; expect(deleteManyOrder).toBeLessThan(createOrder); }); @@ -115,13 +124,14 @@ describe("auth.resetPassword", () => { const result = await authRouter.createCaller(ctx).resetPassword({ token: "valid-token", - password: "NewPassword1!", + password: "NewPassword12!", }); expect(result).toEqual({ success: true }); expect(db.user.update).toHaveBeenCalledWith({ where: { email: "user@example.com" }, data: { passwordHash: "$argon2id$newhash" }, + select: { id: true }, }); expect(db.passwordResetToken.update).toHaveBeenCalledWith({ where: { token: "valid-token" }, @@ -134,12 +144,12 @@ describe("auth.resetPassword", () => { const ctx = makeCtx(db); await expect( - authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1!" }), + authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1234!" }), ).rejects.toThrow(TRPCError); const err = await authRouter .createCaller(ctx) - .resetPassword({ token: "bad-token", password: "Password1!" }) + .resetPassword({ token: "bad-token", password: "Password1234!" }) .catch((e: TRPCError) => e); expect((err as TRPCError).code).toBe("NOT_FOUND"); }); @@ -156,7 +166,7 @@ describe("auth.resetPassword", () => { const err = await authRouter .createCaller(ctx) - .resetPassword({ token: "used-token", password: "Password1!" }) + .resetPassword({ token: "used-token", password: "Password1234!" }) .catch((e: TRPCError) => e); expect((err as TRPCError).code).toBe("BAD_REQUEST"); expect((err as TRPCError).message).toMatch(/already been used/); @@ -174,7 +184,7 @@ describe("auth.resetPassword", () => { const err = await authRouter .createCaller(ctx) - .resetPassword({ token: "expired-token", password: "Password1!" }) + .resetPassword({ token: "expired-token", password: "Password1234!" }) .catch((e: TRPCError) => e); expect((err as TRPCError).code).toBe("BAD_REQUEST"); expect((err as TRPCError).message).toMatch(/expired/); diff --git a/packages/api/src/__tests__/reset-password.test.ts b/packages/api/src/__tests__/reset-password.test.ts index 9bea00a..f1b2d44 100644 --- a/packages/api/src/__tests__/reset-password.test.ts +++ b/packages/api/src/__tests__/reset-password.test.ts @@ -28,6 +28,9 @@ function makeCtx(userRow: Record | null = null) { findUnique: vi.fn().mockResolvedValue(userRow), update: vi.fn().mockResolvedValue({}), }, + activeSession: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, } as never, dbUser: { id: "admin_1" }, }; @@ -38,13 +41,15 @@ const EXISTING_USER = { id: "user_1", name: "Alice", email: "alice@example.com" // ── Tests ──────────────────────────────────────────────────────────────────── describe("setUserPassword — happy path", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("hashes the new password and updates the DB", async () => { const ctx = makeCtx(EXISTING_USER); - const result = await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" }); + const result = await setUserPassword(ctx, { userId: "user_1", password: "NewPassword123!" }); - expect(hashMock).toHaveBeenCalledWith("NewPass123!"); + expect(hashMock).toHaveBeenCalledWith("NewPassword123!"); expect(ctx.db.user.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "user_1" }, @@ -57,7 +62,7 @@ describe("setUserPassword — happy path", () => { it("creates an audit entry with summary 'Password reset by admin'", async () => { const { createAuditEntry } = await import("../lib/audit.js"); const ctx = makeCtx(EXISTING_USER); - await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" }); + await setUserPassword(ctx, { userId: "user_1", password: "NewPassword123!" }); // createAuditEntry is called fire-and-forget (void), so we give microtasks a tick await Promise.resolve(); @@ -68,23 +73,28 @@ describe("setUserPassword — happy path", () => { }); describe("setUserPassword — not found", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("throws when the user does not exist", async () => { const ctx = makeCtx(null); // findUnique returns null await expect( - setUserPassword(ctx, { userId: "ghost", password: "NewPass123!" }), + setUserPassword(ctx, { userId: "ghost", password: "NewPassword123!" }), ).rejects.toThrow(); }); }); describe("SetUserPasswordInputSchema — validation", () => { it("accepts a valid input", () => { - const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "Valid123!" }); + const result = SetUserPasswordInputSchema.safeParse({ + userId: "u1", + password: "ValidPass123!", + }); expect(result.success).toBe(true); }); - it("rejects a password shorter than 8 characters", () => { + it("rejects a password shorter than 12 characters", () => { const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "short" }); expect(result.success).toBe(false); }); diff --git a/packages/api/src/__tests__/user-router-auth.test.ts b/packages/api/src/__tests__/user-router-auth.test.ts index 8b2ab7a..bb9f40d 100644 --- a/packages/api/src/__tests__/user-router-auth.test.ts +++ b/packages/api/src/__tests__/user-router-auth.test.ts @@ -36,11 +36,16 @@ function createContext( describe("user router authorization", () => { it("requires authentication for self-service profile lookups", async () => { const findUnique = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { session: false }, + ), + ); await expect(caller.me()).rejects.toMatchObject({ code: "UNAUTHORIZED", @@ -52,11 +57,16 @@ describe("user router authorization", () => { it("requires authentication for dashboard layout reads", async () => { const findUnique = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { session: false }, + ), + ); await expect(caller.getDashboardLayout()).rejects.toMatchObject({ code: "UNAUTHORIZED", @@ -69,12 +79,17 @@ describe("user router authorization", () => { it("requires authentication for favorite project toggles", async () => { const findUnique = vi.fn(); const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - update, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + update, + }, + }, + { session: false }, + ), + ); await expect(caller.toggleFavoriteProject({ projectId: "project_1" })).rejects.toMatchObject({ code: "UNAUTHORIZED", @@ -87,15 +102,22 @@ describe("user router authorization", () => { it("requires authentication for dashboard layout saves", async () => { const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - update, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + update, + }, + }, + { session: false }, + ), + ); - await expect(caller.saveDashboardLayout({ - layout: { version: 2, gridCols: 12, widgets: [] }, - })).rejects.toMatchObject({ + await expect( + caller.saveDashboardLayout({ + layout: { version: 2, gridCols: 12, widgets: [] }, + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Authentication required", }); @@ -105,11 +127,16 @@ describe("user router authorization", () => { it("requires authentication for favorite project reads", async () => { const findUnique = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { session: false }, + ), + ); await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({ code: "UNAUTHORIZED", @@ -122,21 +149,28 @@ describe("user router authorization", () => { it("requires authentication for column preference reads and writes", async () => { const findUnique = vi.fn(); const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - update, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + update, + }, + }, + { session: false }, + ), + ); await expect(caller.getColumnPreferences()).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Authentication required", }); - await expect(caller.setColumnPreferences({ - view: "resources", - visible: ["name"], - })).rejects.toMatchObject({ + await expect( + caller.setColumnPreferences({ + view: "resources", + visible: ["name"], + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Authentication required", }); @@ -147,11 +181,16 @@ describe("user router authorization", () => { it("requires authentication for MFA status lookups", async () => { const findUniqueOrThrow = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUniqueOrThrow, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUniqueOrThrow, + }, + }, + { session: false }, + ), + ); await expect(caller.getMfaStatus()).rejects.toMatchObject({ code: "UNAUTHORIZED", @@ -163,11 +202,13 @@ describe("user router authorization", () => { it("forbids regular users from listing assignable users", async () => { const findMany = vi.fn(); - const caller = createCaller(createContext({ - user: { - findMany, - }, - })); + const caller = createCaller( + createContext({ + user: { + findMany, + }, + }), + ); await expect(caller.listAssignable()).rejects.toMatchObject({ code: "FORBIDDEN", @@ -178,14 +219,19 @@ describe("user router authorization", () => { }); it("allows managers to list assignable users", async () => { - const findMany = vi.fn().mockResolvedValue([ - { id: "user_1", name: "Alice", email: "alice@example.com" }, - ]); - const caller = createCaller(createContext({ - user: { - findMany, - }, - }, { role: SystemRole.MANAGER })); + const findMany = vi + .fn() + .mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]); + const caller = createCaller( + createContext( + { + user: { + findMany, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); const result = await caller.listAssignable(); @@ -202,11 +248,16 @@ describe("user router authorization", () => { it("forbids non-admin users from reading effective permissions", async () => { const findUnique = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); await expect(caller.getEffectivePermissions({ userId: "user_2" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -219,19 +270,26 @@ describe("user router authorization", () => { it("forbids non-admin users from setting explicit permission overrides", async () => { const findUnique = vi.fn(); const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - update, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + update, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); - await expect(caller.setPermissions({ - userId: "user_2", - overrides: { - granted: ["manageProjects"], - }, - })).rejects.toMatchObject({ + await expect( + caller.setPermissions({ + userId: "user_2", + overrides: { + granted: ["manageProjects"], + }, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: "Admin role required", }); @@ -243,12 +301,17 @@ describe("user router authorization", () => { it("forbids non-admin users from resetting permission overrides", async () => { const findUnique = vi.fn(); const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - update, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + update, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -261,11 +324,16 @@ describe("user router authorization", () => { it("forbids non-admin users from disabling TOTP for other users", async () => { const findUnique = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); await expect(caller.disableTotp({ userId: "user_2" })).rejects.toMatchObject({ code: "FORBIDDEN", @@ -280,21 +348,28 @@ describe("user router authorization", () => { const resourceFindUnique = vi.fn(); const updateMany = vi.fn(); const update = vi.fn(); - const caller = createCaller(createContext({ - user: { - findUnique: userFindUnique, - }, - resource: { - findUnique: resourceFindUnique, - updateMany, - update, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findUnique: userFindUnique, + }, + resource: { + findUnique: resourceFindUnique, + updateMany, + update, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); - await expect(caller.linkResource({ - userId: "user_2", - resourceId: "resource_1", - })).rejects.toMatchObject({ + await expect( + caller.linkResource({ + userId: "user_2", + resourceId: "resource_1", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: "Admin role required", }); @@ -309,15 +384,20 @@ describe("user router authorization", () => { const userFindMany = vi.fn(); const resourceFindFirst = vi.fn(); const resourceUpdate = vi.fn(); - const caller = createCaller(createContext({ - user: { - findMany: userFindMany, - }, - resource: { - findFirst: resourceFindFirst, - update: resourceUpdate, - }, - }, { role: SystemRole.MANAGER })); + const caller = createCaller( + createContext( + { + user: { + findMany: userFindMany, + }, + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + }, + { role: SystemRole.MANAGER }, + ), + ); await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({ code: "FORBIDDEN", @@ -334,21 +414,27 @@ describe("user router authorization", () => { id: "user_1", totpEnabled: false, totpSecret: null, + lastTotpAt: null, }); - const caller = createCaller(createContext({ - user: { - findUnique, - }, - }, { session: false })); + const caller = createCaller( + createContext( + { + user: { + findUnique, + }, + }, + { session: false }, + ), + ); await expect(caller.verifyTotp({ userId: "user_1", token: "123456" })).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: "TOTP is not enabled for this user.", + code: "UNAUTHORIZED", + message: "Invalid TOTP token.", }); expect(findUnique).toHaveBeenCalledWith({ where: { id: "user_1" }, - select: { id: true, totpSecret: true, totpEnabled: true }, + select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, }); }); }); diff --git a/packages/api/src/__tests__/user-router.test.ts b/packages/api/src/__tests__/user-router.test.ts index 30399b9..02582dc 100644 --- a/packages/api/src/__tests__/user-router.test.ts +++ b/packages/api/src/__tests__/user-router.test.ts @@ -81,10 +81,12 @@ describe("user.linkResource", () => { }, }); - await expect(caller.linkResource({ - userId: "missing_user", - resourceId: "resource_1", - })).rejects.toMatchObject({ + await expect( + caller.linkResource({ + userId: "missing_user", + resourceId: "resource_1", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "User not found", }); @@ -112,10 +114,12 @@ describe("user.linkResource", () => { }, }); - await expect(caller.linkResource({ - userId: "user_1", - resourceId: "missing_resource", - })).rejects.toMatchObject({ + await expect( + caller.linkResource({ + userId: "user_1", + resourceId: "missing_resource", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Resource not found", }); @@ -141,10 +145,12 @@ describe("user.linkResource", () => { }, }); - await expect(caller.linkResource({ - userId: "user_1", - resourceId: "resource_1", - })).rejects.toMatchObject({ + await expect( + caller.linkResource({ + userId: "user_1", + resourceId: "resource_1", + }), + ).rejects.toMatchObject({ code: "CONFLICT", message: "Resource is already linked to another user", }); @@ -155,7 +161,8 @@ describe("user.linkResource", () => { it("unlinks existing assignments before linking the requested resource", async () => { const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); - const updateMany = vi.fn() + const updateMany = vi + .fn() .mockResolvedValueOnce({ count: 1 }) .mockResolvedValueOnce({ count: 1 }); const caller = createAdminCaller({ @@ -224,7 +231,8 @@ describe("user.linkResource", () => { it("returns CONFLICT when the resource link changes between validation and update", async () => { const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" }); const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null }); - const updateMany = vi.fn() + const updateMany = vi + .fn() .mockResolvedValueOnce({ count: 0 }) .mockResolvedValueOnce({ count: 0 }); const caller = createAdminCaller({ @@ -237,10 +245,12 @@ describe("user.linkResource", () => { }, }); - await expect(caller.linkResource({ - userId: "user_1", - resourceId: "resource_1", - })).rejects.toMatchObject({ + await expect( + caller.linkResource({ + userId: "user_1", + resourceId: "resource_1", + }), + ).rejects.toMatchObject({ code: "CONFLICT", message: "Resource link changed during update. Please retry.", }); @@ -249,7 +259,9 @@ describe("user.linkResource", () => { describe("user admin account management", () => { it("counts users active within the last five minutes", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); + const nowSpy = vi + .spyOn(Date, "now") + .mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf()); const count = vi.fn().mockResolvedValue(4); const caller = createAdminCaller({ user: { @@ -279,24 +291,29 @@ describe("user admin account management", () => { email: "alice@example.com", }); const update = vi.fn().mockResolvedValue({}); + const deleteMany = vi.fn().mockResolvedValue({ count: 0 }); const caller = createAdminCaller({ user: { findUnique, update, }, + activeSession: { + deleteMany, + }, }); const result = await caller.setPassword({ userId: "user_2", - password: "secret123", + password: "SecurePass123!", }); expect(result).toEqual({ success: true }); - expect(argon2HashMock).toHaveBeenCalledWith("secret123"); + expect(argon2HashMock).toHaveBeenCalledWith("SecurePass123!"); expect(update).toHaveBeenCalledWith({ where: { id: "user_2" }, data: { passwordHash: "hashed-secret" }, }); + expect(deleteMany).toHaveBeenCalledWith({ where: { userId: "user_2" } }); }); it("updates the selected user's display name", async () => { @@ -436,6 +453,7 @@ describe("user permission overrides", () => { expect(update).toHaveBeenCalledWith({ where: { id: "user_2" }, data: { permissionOverrides: overrides }, + select: { id: true, name: true, email: true, permissionOverrides: true }, }); }); @@ -472,6 +490,7 @@ describe("user permission overrides", () => { expect(update).toHaveBeenCalledWith({ where: { id: "user_2" }, data: { permissionOverrides: Prisma.DbNull }, + select: { id: true, name: true, email: true, permissionOverrides: true }, }); }); @@ -709,7 +728,7 @@ describe("user profile and TOTP self-service", () => { expect(result).toEqual({ enabled: true }); expect(update).toHaveBeenCalledWith({ where: { id: "user_admin" }, - data: { totpEnabled: true }, + data: { totpEnabled: true, lastTotpAt: expect.any(Date) }, }); }); @@ -721,10 +740,13 @@ describe("user profile and TOTP self-service", () => { id: "user_admin", totpSecret: "MOCKSECRET", totpEnabled: true, + lastTotpAt: null, }); + const update = vi.fn().mockResolvedValue({}); const caller = createAdminCaller({ user: { findUnique, + update, }, }); @@ -733,7 +755,11 @@ describe("user profile and TOTP self-service", () => { expect(result).toEqual({ valid: true }); expect(findUnique).toHaveBeenCalledWith({ where: { id: "user_admin" }, - select: { id: true, totpSecret: true, totpEnabled: true }, + select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "user_admin" }, + data: { lastTotpAt: expect.any(Date) }, }); }); @@ -752,7 +778,9 @@ describe("user profile and TOTP self-service", () => { }, }); - await expect(caller.verifyTotp({ userId: "user_admin", token: "123456" })).rejects.toMatchObject({ + await expect( + caller.verifyTotp({ userId: "user_admin", token: "123456" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Invalid TOTP token.", }); @@ -763,9 +791,7 @@ describe("user dashboard and favorites", () => { it("returns null layout when stored data has no valid widget types (bug #27 guard)", async () => { const findUnique = vi.fn().mockResolvedValue({ dashboardLayout: { - widgets: [ - { id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }, - ], + widgets: [{ id: "peakTimes", position: { x: 0, y: 0, w: 4, h: 3 } }], }, updatedAt: new Date("2026-03-30T18:00:00.000Z"), }); diff --git a/packages/api/src/__tests__/user-self-service-mfa.test.ts b/packages/api/src/__tests__/user-self-service-mfa.test.ts index 4388a2a..bbfcb90 100644 --- a/packages/api/src/__tests__/user-self-service-mfa.test.ts +++ b/packages/api/src/__tests__/user-self-service-mfa.test.ts @@ -21,23 +21,33 @@ const totpValidateMock = vi.hoisted(() => vi.fn<() => number | null>()); vi.mock("otpauth", () => { class Secret { base32: string; - constructor() { this.base32 = "TESTBASE32SECRET"; } - static fromBase32(v: string) { return v; } + constructor() { + this.base32 = "TESTBASE32SECRET"; + } + static fromBase32(v: string) { + return v; + } } class TOTP { - validate(_args: { token: string; window: number }) { return totpValidateMock(); } - toString() { return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; } + validate(_args: { token: string; window: number }) { + return totpValidateMock(); + } + toString() { + return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; + } } return { Secret, TOTP }; }); // ─── rate-limit mock ────────────────────────────────────────────────────────── // Default: rate limit allows all requests. Override in specific tests. -const totpRateLimiterMock = vi.hoisted(() => vi.fn(async (_key: string) => ({ - allowed: true, - remaining: 9, - resetAt: new Date(Date.now() + 30_000), -}))); +const totpRateLimiterMock = vi.hoisted(() => + vi.fn(async (_key: string) => ({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 30_000), + })), +); vi.mock("../middleware/rate-limit.js", () => ({ totpRateLimiter: totpRateLimiterMock, @@ -85,6 +95,7 @@ function makePublicCtx(dbOverrides: Record = {}) { db: { user: { findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), ...((dbOverrides.user as object | undefined) ?? {}), }, }, @@ -94,7 +105,9 @@ function makePublicCtx(dbOverrides: Record = {}) { // ─── generateTotpSecret ─────────────────────────────────────────────────────── describe("generateTotpSecret", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("writes the base32 secret to the user record", async () => { const ctx = makeSelfServiceCtx(); @@ -134,14 +147,13 @@ describe("verifyAndEnableTotp", () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, }); - const result = await verifyAndEnableTotp( - ctx as Parameters[0], - { token: "123456" }, - ); + const result = await verifyAndEnableTotp(ctx as Parameters[0], { + token: "123456", + }); expect(result).toEqual({ enabled: true }); expect(ctx.db.user.update).toHaveBeenCalledWith({ where: { id: "user_1" }, - data: { totpEnabled: true }, + data: { totpEnabled: true, lastTotpAt: expect.any(Date) }, }); }); @@ -178,7 +190,9 @@ describe("verifyAndEnableTotp", () => { const ctx = makeSelfServiceCtx({ user: { findUnique: vi.fn().mockResolvedValue(baseUser) }, }); - await verifyAndEnableTotp(ctx as Parameters[0], { token: "123456" }); + await verifyAndEnableTotp(ctx as Parameters[0], { + token: "123456", + }); // Audit entry is fire-and-forget; wait one tick await new Promise((r) => setTimeout(r, 0)); expect(ctx.db.auditLog.create).toHaveBeenCalled(); @@ -191,7 +205,11 @@ describe("verifyTotp", () => { beforeEach(() => { vi.clearAllMocks(); totpValidateMock.mockReset(); - totpRateLimiterMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 30_000) }); + totpRateLimiterMock.mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 30_000), + }); }); const mfaUser = { id: "user_1", totpSecret: "TESTBASE32SECRET", totpEnabled: true }; @@ -214,13 +232,13 @@ describe("verifyTotp", () => { ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." })); }); - it("throws BAD_REQUEST when user does not have MFA enabled", async () => { + it("throws UNAUTHORIZED when user does not have MFA enabled (prevents user enumeration)", async () => { const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) }, }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), - ).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." })); + ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." })); }); it("throws BAD_REQUEST when user has no TOTP secret (inconsistent state)", async () => { @@ -233,11 +251,20 @@ describe("verifyTotp", () => { }); it("throws TOO_MANY_REQUESTS when rate limit is exceeded", async () => { - totpRateLimiterMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 30_000) }); + totpRateLimiterMock.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 30_000), + }); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); await expect( verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }), - ).rejects.toThrow(new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." })); + ).rejects.toThrow( + new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many TOTP attempts. Please wait before trying again.", + }), + ); }); it("does not check the token when rate limit is exceeded", async () => { @@ -253,7 +280,10 @@ describe("verifyTotp", () => { it("calls the rate limiter with the userId as key", async () => { totpValidateMock.mockReturnValue(0); const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } }); - await verifyTotp(ctx as Parameters[0], { userId: "user_1", token: "123456" }); + await verifyTotp(ctx as Parameters[0], { + userId: "user_1", + token: "123456", + }); expect(totpRateLimiterMock).toHaveBeenCalledWith("user_1"); }); }); @@ -261,7 +291,9 @@ describe("verifyTotp", () => { // ─── getCurrentMfaStatus ────────────────────────────────────────────────────── describe("getCurrentMfaStatus", () => { - beforeEach(() => { vi.clearAllMocks(); }); + beforeEach(() => { + vi.clearAllMocks(); + }); it("returns totpEnabled: true when MFA is active", async () => { const ctx = makeSelfServiceCtx({ diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index a8dd582..d005ee6 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -52,13 +52,19 @@ import { insightsRouter } from "./insights.js"; import { scenarioRouter } from "./scenario.js"; import { allocationRouter } from "./allocation/index.js"; import { staffingRouter } from "./staffing.js"; -import { advancedTimelineToolDefinitions, createAdvancedTimelineExecutors } from "./assistant-tools/advanced-timeline.js"; +import { + advancedTimelineToolDefinitions, + createAdvancedTimelineExecutors, +} from "./assistant-tools/advanced-timeline.js"; import { allocationPlanningMutationToolDefinitions, allocationPlanningReadToolDefinitions, createAllocationPlanningExecutors, } from "./assistant-tools/allocation-planning.js"; -import { settingsAdminToolDefinitions, createSettingsAdminExecutors } from "./assistant-tools/settings-admin.js"; +import { + settingsAdminToolDefinitions, + createSettingsAdminExecutors, +} from "./assistant-tools/settings-admin.js"; import { createVacationHolidayExecutors, vacationHolidayMutationToolDefinitions, @@ -184,36 +190,84 @@ export const MUTATION_TOOLS = new Set([ "cancel_dispo_import_batch", "resolve_dispo_staged_record", "commit_dispo_import_batch", - "create_allocation", "cancel_allocation", "update_allocation_status", - "update_timeline_allocation_inline", "apply_timeline_project_shift", - "quick_assign_timeline_resource", "batch_quick_assign_timeline_resources", + "create_allocation", + "cancel_allocation", + "update_allocation_status", + "update_timeline_allocation_inline", + "apply_timeline_project_shift", + "quick_assign_timeline_resource", + "batch_quick_assign_timeline_resources", "batch_shift_timeline_allocations", - "update_resource", "deactivate_resource", "create_resource", - "update_project", "create_project", "delete_project", - "create_vacation", "approve_vacation", "reject_vacation", "cancel_vacation", - "set_entitlement", "create_demand", "fill_demand", - "generate_project_cover", "remove_project_cover", - "create_role", "update_role", "delete_role", - "create_client", "update_client", "delete_client", - "create_org_unit", "update_org_unit", - "create_country", "update_country", - "create_metro_city", "update_metro_city", "delete_metro_city", - "create_holiday_calendar", "update_holiday_calendar", "delete_holiday_calendar", - "create_holiday_calendar_entry", "update_holiday_calendar_entry", "delete_holiday_calendar_entry", - "send_broadcast", "create_task_for_user", "create_reminder", - "update_task_status", "execute_task_action", - "create_comment", "resolve_comment", "mark_notification_read", - "save_dashboard_layout", "toggle_favorite_project", - "set_column_preferences", "generate_totp_secret", "verify_and_enable_totp", - "create_user", "set_user_password", "update_user_role", "update_user_name", - "link_user_resource", "auto_link_users_by_email", "set_user_permissions", - "reset_user_permissions", "disable_user_totp", - "create_notification", "update_reminder", "delete_reminder", - "delete_notification", "assign_task", - "clone_estimate", "update_estimate_draft", "submit_estimate_version", - "approve_estimate_version", "create_estimate_revision", - "create_estimate_export", "create_estimate_planning_handoff", - "generate_estimate_weekly_phasing", "update_estimate_commercial_terms", + "update_resource", + "deactivate_resource", + "create_resource", + "update_project", + "create_project", + "delete_project", + "create_vacation", + "approve_vacation", + "reject_vacation", + "cancel_vacation", + "set_entitlement", + "create_demand", + "fill_demand", + "generate_project_cover", + "remove_project_cover", + "create_role", + "update_role", + "delete_role", + "create_client", + "update_client", + "delete_client", + "create_org_unit", + "update_org_unit", + "create_country", + "update_country", + "create_metro_city", + "update_metro_city", + "delete_metro_city", + "create_holiday_calendar", + "update_holiday_calendar", + "delete_holiday_calendar", + "create_holiday_calendar_entry", + "update_holiday_calendar_entry", + "delete_holiday_calendar_entry", + "send_broadcast", + "create_task_for_user", + "create_reminder", + "update_task_status", + "execute_task_action", + "create_comment", + "resolve_comment", + "mark_notification_read", + "save_dashboard_layout", + "toggle_favorite_project", + "set_column_preferences", + "generate_totp_secret", + "verify_and_enable_totp", + "create_user", + "set_user_password", + "update_user_role", + "update_user_name", + "link_user_resource", + "auto_link_users_by_email", + "set_user_permissions", + "reset_user_permissions", + "disable_user_totp", + "create_notification", + "update_reminder", + "delete_reminder", + "delete_notification", + "assign_task", + "clone_estimate", + "update_estimate_draft", + "submit_estimate_version", + "approve_estimate_version", + "create_estimate_revision", + "create_estimate_export", + "create_estimate_planning_handoff", + "generate_estimate_weekly_phasing", + "update_estimate_commercial_terms", ]); export const ADVANCED_ASSISTANT_TOOLS = new Set([ @@ -281,7 +335,9 @@ class AssistantVisibleError extends Error { function assertPermission(ctx: ToolContext, perm: PermissionKey): void { if (!ctx.permissions.has(perm)) { - throw new AssistantVisibleError(`Permission denied: you need the "${perm}" permission to perform this action.`); + throw new AssistantVisibleError( + `Permission denied: you need the "${perm}" permission to perform this action.`, + ); } } @@ -382,14 +438,16 @@ function createUtcDate(year: number, monthIndex: number, day: number): Date { return new Date(Date.UTC(year, monthIndex, day)); } -function resolveHolidayPeriod(input: { - year?: number; - periodStart?: string; - periodEnd?: string; -}): { year: number | null; periodStart: Date; periodEnd: Date } { +function resolveHolidayPeriod(input: { year?: number; periodStart?: string; periodEnd?: string }): { + year: number | null; + periodStart: Date; + periodEnd: Date; +} { if (input.periodStart || input.periodEnd) { if (!input.periodStart || !input.periodEnd) { - throw new AssistantVisibleError("periodStart and periodEnd must both be provided when using a custom holiday range."); + throw new AssistantVisibleError( + "periodStart and periodEnd must both be provided when using a custom holiday range.", + ); } const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`); @@ -440,14 +498,18 @@ const ASSISTANT_VACATION_REQUEST_TYPES = [ function parseAssistantVacationRequestType(input: string): VacationType { const normalized = input.trim().toUpperCase(); if (normalized === VacationType.PUBLIC_HOLIDAY) { - throw new AssistantVisibleError("PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead."); + throw new AssistantVisibleError( + "PUBLIC_HOLIDAY requests cannot be created manually. Manage public holidays through holiday calendars instead.", + ); } if ((ASSISTANT_VACATION_REQUEST_TYPES as readonly string[]).includes(normalized)) { return normalized as VacationType; } - throw new AssistantVisibleError(`Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`); + throw new AssistantVisibleError( + `Invalid vacation type: ${input}. Valid types: ${ASSISTANT_VACATION_REQUEST_TYPES.join(", ")}.`, + ); } function parseIsoDate(value: string, fieldName: string): Date { @@ -458,10 +520,7 @@ function parseIsoDate(value: string, fieldName: string): Date { return parsed; } -function parseOptionalIsoDate( - value: string | undefined, - fieldName: string, -): Date | undefined { +function parseOptionalIsoDate(value: string | undefined, fieldName: string): Date | undefined { return value ? parseIsoDate(value, fieldName) : undefined; } @@ -473,10 +532,7 @@ function parseDateTime(value: string, fieldName: string): Date { return parsed; } -function parseOptionalDateTime( - value: string | undefined, - fieldName: string, -): Date | undefined { +function parseOptionalDateTime(value: string | undefined, fieldName: string): Date | undefined { return value ? parseDateTime(value, fieldName) : undefined; } @@ -509,9 +565,7 @@ function toAssistantNotFoundError( return null; } -function toAssistantAllocationNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantAllocationNotFoundError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError) { if (error.code === "NOT_FOUND") { return { error: "Allocation not found with the given criteria." }; @@ -533,10 +587,7 @@ function toAssistantProjectNotFoundError( error: unknown, identifier: string, ): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - `Project not found: ${identifier}`, - ); + return toAssistantNotFoundError(error, `Project not found: ${identifier}`); } function toAssistantTimelineMutationError( @@ -591,13 +642,8 @@ function toAssistantTimelineMutationError( return null; } -function toAssistantVacationNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Vacation not found with the given criteria.", - ); +function toAssistantVacationNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Vacation not found with the given criteria."); } function toAssistantVacationMutationError( @@ -665,18 +711,11 @@ function toAssistantProjectCreationError( return null; } -function toAssistantDemandNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Demand not found with the given criteria.", - ); +function toAssistantDemandNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Demand not found with the given criteria."); } -function toAssistantDemandFillError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantDemandFillError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantDemandNotFoundError(error); if (notFound) { return notFound; @@ -689,9 +728,7 @@ function toAssistantDemandFillError( return null; } -function toAssistantEstimateNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantEstimateNotFoundError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error.message.includes("version")) { return { error: "Estimate version not found with the given criteria." }; @@ -712,10 +749,10 @@ function toAssistantEstimateReadError( } if ( - context === "weeklyPhasing" - && error instanceof TRPCError - && error.code === "PRECONDITION_FAILED" - && error.message === "Estimate has no versions" + context === "weeklyPhasing" && + error instanceof TRPCError && + error.code === "PRECONDITION_FAILED" && + error.message === "Estimate has no versions" ) { return { error: "Estimate version not found with the given criteria." }; } @@ -735,9 +772,7 @@ function toAssistantHolidayCalendarNotFoundError( ); } -function toAssistantHolidayCalendarMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantHolidayCalendarMutationError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantHolidayCalendarNotFoundError(error); if (notFound) { return notFound; @@ -754,18 +789,14 @@ function toAssistantHolidayCalendarMutationError( return null; } -function toAssistantHolidayEntryNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantHolidayEntryNotFoundError(error: unknown): AssistantToolErrorResult | null { return toAssistantNotFoundError( error, "Holiday calendar entry not found with the given criteria.", ); } -function toAssistantHolidayEntryMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantHolidayEntryMutationError(error: unknown): AssistantToolErrorResult | null { const calendarNotFound = toAssistantHolidayCalendarNotFoundError(error); if (calendarNotFound) { return calendarNotFound; @@ -783,13 +814,8 @@ function toAssistantHolidayEntryMutationError( return null; } -function toAssistantRoleNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Role not found with the given criteria.", - ); +function toAssistantRoleNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Role not found with the given criteria."); } function toAssistantRoleMutationError( @@ -829,19 +855,22 @@ function toAssistantClientMutationError( if (action === "delete" && error instanceof TRPCError && error.code === "PRECONDITION_FAILED") { if (error.message.includes("project")) { - return { error: "Client cannot be deleted while it still has projects. Deactivate it instead." }; + return { + error: "Client cannot be deleted while it still has projects. Deactivate it instead.", + }; } if (error.message.includes("child client")) { - return { error: "Client cannot be deleted while it still has child clients. Remove or reassign them first." }; + return { + error: + "Client cannot be deleted while it still has child clients. Remove or reassign them first.", + }; } } return null; } -function toAssistantOrgUnitNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantOrgUnitNotFoundError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error.message.includes("Parent org unit")) { return { error: "Parent org unit not found with the given criteria." }; @@ -852,9 +881,7 @@ function toAssistantOrgUnitNotFoundError( return null; } -function toAssistantOrgUnitMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantOrgUnitMutationError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantOrgUnitNotFoundError(error); if (notFound) { return notFound; @@ -869,9 +896,7 @@ function toAssistantOrgUnitMutationError( return null; } -function toAssistantCountryNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantCountryNotFoundError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Country not found with the given criteria." }; } @@ -879,9 +904,7 @@ function toAssistantCountryNotFoundError( return null; } -function toAssistantCountryMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantCountryMutationError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantCountryNotFoundError(error); if (notFound) { return notFound; @@ -894,9 +917,7 @@ function toAssistantCountryMutationError( return null; } -function toAssistantResourceCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantResourceCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError) { if (error.code === "CONFLICT") { return { error: "A resource with this EID or email already exists." }; @@ -935,16 +956,18 @@ function toAssistantResourceCreationError( if (errorText.includes("country")) { return { error: "Country not found with the given criteria." }; } - if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) { + if ( + errorText.includes("orgunit") || + errorText.includes("org_unit") || + errorText.includes("org unit") + ) { return { error: "Org unit not found with the given criteria." }; } return { error: "The selected role, country, or org unit no longer exists." }; } -function toAssistantResourceMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantResourceMutationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Resource not found with the given criteria." }; } @@ -969,16 +992,18 @@ function toAssistantResourceMutationError( if (errorText.includes("country")) { return { error: "Country not found with the given criteria." }; } - if (errorText.includes("orgunit") || errorText.includes("org_unit") || errorText.includes("org unit")) { + if ( + errorText.includes("orgunit") || + errorText.includes("org_unit") || + errorText.includes("org unit") + ) { return { error: "Org unit not found with the given criteria." }; } return { error: "Resource not found with the given criteria." }; } -function toAssistantProjectMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantProjectMutationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Project not found with the given criteria." }; } @@ -1007,9 +1032,7 @@ function toAssistantProjectMutationError( return { error: "Project not found with the given criteria." }; } -function toAssistantMetroCityMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantMetroCityMutationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error.message.includes("Country")) { return { error: "Country not found with the given criteria." }; @@ -1024,9 +1047,7 @@ function toAssistantMetroCityMutationError( return null; } -function toAssistantDemandCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantDemandCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error.message.includes("Role")) { return { error: "Role not found with the given criteria." }; @@ -1054,9 +1075,7 @@ function toAssistantDemandCreationError( return null; } -function toAssistantVacationCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantVacationCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError) { if (error.code === "FORBIDDEN") { return { error: "You can only create vacation requests for your own resource." }; @@ -1088,9 +1107,7 @@ function toAssistantVacationCreationError( return { error: "Resource not found with the given criteria." }; } -function toAssistantEntitlementMutationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantEntitlementMutationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Resource not found with the given criteria." }; } @@ -1111,9 +1128,7 @@ function toAssistantEntitlementMutationError( return { error: "Resource not found with the given criteria." }; } -function toAssistantEstimateCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantEstimateCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Project not found with the given criteria." }; } @@ -1137,11 +1152,17 @@ function toAssistantEstimateCreationError( if (errorText.includes("resource")) { return { error: "Resource not found with the given criteria." }; } - if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) { + if ( + errorText.includes("scopeitem") || + errorText.includes("scope_item") || + errorText.includes("scope item") + ) { return { error: "Estimate scope item not found with the given criteria." }; } - return { error: "One of the referenced project, role, resource, or scope items no longer exists." }; + return { + error: "One of the referenced project, role, resource, or scope items no longer exists.", + }; } function toAssistantEstimateMutationError( @@ -1201,7 +1222,9 @@ function toAssistantEstimateMutationError( return { error: "Commercial terms can only be edited on working versions." }; default: if (error.message.startsWith("Project window has no working days for demand line")) { - return { error: "The linked project window has no working days for at least one demand line." }; + return { + error: "The linked project window has no working days for at least one demand line.", + }; } } } @@ -1227,18 +1250,32 @@ function toAssistantEstimateMutationError( if (errorText.includes("resource")) { return { error: "Resource not found with the given criteria." }; } - if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) { + if ( + errorText.includes("scopeitem") || + errorText.includes("scope_item") || + errorText.includes("scope item") + ) { return { error: "Estimate scope item not found with the given criteria." }; } - return { error: "One of the referenced project, role, resource, or scope items no longer exists." }; + return { + error: "One of the referenced project, role, resource, or scope items no longer exists.", + }; } if (prismaError.code === "P2025") { const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); - if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) { + if ( + errorText.includes("estimatedemandline") || + errorText.includes("estimate_demand_line") || + errorText.includes("estimate demand line") + ) { return { error: "Estimate demand line not found with the given criteria." }; } - if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) { + if ( + errorText.includes("estimateversion") || + errorText.includes("estimate_version") || + errorText.includes("estimate version") + ) { return { error: "Estimate version not found with the given criteria." }; } if (errorText.includes("estimate")) { @@ -1278,7 +1315,7 @@ function toAssistantUserMutationError( for (const issue of validationIssues) { const field = issue.path[0]; if (field === "password" && issue.code === "too_small") { - return { error: "Password must be at least 8 characters." }; + return { error: "Password must be at least 12 characters." }; } if (field === "name" && issue.code === "too_small") { @@ -1290,8 +1327,8 @@ function toAssistantUserMutationError( } } - if (error.message.includes("Password must be at least 8 characters")) { - return { error: "Password must be at least 8 characters." }; + if (error.message.includes("Password must be at least 12 characters")) { + return { error: "Password must be at least 12 characters." }; } if (error.message.includes("Name is required")) { @@ -1325,25 +1362,26 @@ function getTrpcValidationIssues(error: TRPCError): Array<{ } return parsed - .filter((issue): issue is { code?: unknown; path?: unknown } => issue !== null && typeof issue === "object") - .map((issue) => ( + .filter( + (issue): issue is { code?: unknown; path?: unknown } => + issue !== null && typeof issue === "object", + ) + .map((issue) => typeof issue.code === "string" ? { - code: issue.code, - path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], - } + code: issue.code, + path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], + } : { - path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], - } - )); + path: Array.isArray(issue.path) ? issue.path.map((segment) => String(segment)) : [], + }, + ); } catch { return []; } } -function toAssistantUserResourceLinkError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantUserResourceLinkError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "CONFLICT") { if (error.message.includes("already linked")) { return { error: "Resource is already linked to another user." }; @@ -1367,13 +1405,11 @@ function toAssistantUserResourceLinkError( const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase(); const pointsToUser = - errorText.includes("userid") - || errorText.includes("user_id") - || errorText.includes(" user "); + errorText.includes("userid") || errorText.includes("user_id") || errorText.includes(" user "); const pointsToResource = - errorText.includes("resourceid") - || errorText.includes("resource_id") - || errorText.includes(" resource "); + errorText.includes("resourceid") || + errorText.includes("resource_id") || + errorText.includes(" resource "); if (prismaError.code === "P2025") { return { error: "Resource not found with the given criteria." }; @@ -1392,9 +1428,7 @@ function toAssistantUserResourceLinkError( return null; } -function toAssistantTotpEnableError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantTotpEnableError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "BAD_REQUEST") { if (error.message.includes("No TOTP secret generated")) { return { error: "No TOTP secret generated. Call generate_totp_secret first." }; @@ -1419,13 +1453,8 @@ function toAssistantTotpEnableError( return null; } -function toAssistantWebhookNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Webhook not found with the given criteria.", - ); +function toAssistantWebhookNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Webhook not found with the given criteria."); } function toAssistantWebhookMutationError( @@ -1439,9 +1468,7 @@ function toAssistantWebhookMutationError( if (error instanceof TRPCError && error.code === "BAD_REQUEST") { return { - error: action === "create" - ? "Webhook input is invalid." - : "Webhook update input is invalid.", + error: action === "create" ? "Webhook input is invalid." : "Webhook update input is invalid.", }; } @@ -1453,27 +1480,15 @@ function toAssistantWebhookMutationError( return null; } -function toAssistantAuditLogEntryNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Audit log entry not found with the given criteria.", - ); +function toAssistantAuditLogEntryNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Audit log entry not found with the given criteria."); } -function toAssistantTaskNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Task not found with the given criteria.", - ); +function toAssistantTaskNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Task not found with the given criteria."); } -function toAssistantTaskActionError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantTaskActionError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantTaskNotFoundError(error); if (notFound) { return notFound; @@ -1492,14 +1507,19 @@ function toAssistantTaskActionError( } if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - if (error.message.includes("Invalid taskAction format") || error.message.includes("Unknown action")) { + if ( + error.message.includes("Invalid taskAction format") || + error.message.includes("Unknown action") + ) { return { error: "Task action is invalid and cannot be executed." }; } if (error.message === "Vacation not found") { return { error: "Vacation not found with the given criteria." }; } if (error.message.startsWith("Vacation is ") && error.message.includes(", not PENDING")) { - return { error: "Vacation is not pending and cannot be approved or rejected via this task action." }; + return { + error: "Vacation is not pending and cannot be approved or rejected via this task action.", + }; } if (error.message === "Assignment not found") { return { error: "Assignment not found with the given criteria." }; @@ -1517,9 +1537,7 @@ function toAssistantTaskActionError( return null; } -function toAssistantTaskAssignmentError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantTaskAssignmentError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantTaskNotFoundError(error); if (notFound) { return notFound; @@ -1540,36 +1558,19 @@ function toAssistantTaskAssignmentError( return null; } -function toAssistantBroadcastNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Broadcast not found with the given criteria.", - ); +function toAssistantBroadcastNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Broadcast not found with the given criteria."); } -function toAssistantDispoImportBatchNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Import batch not found with the given criteria.", - ); +function toAssistantDispoImportBatchNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Import batch not found with the given criteria."); } -function toAssistantReminderNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { - return toAssistantNotFoundError( - error, - "Reminder not found with the given criteria.", - ); +function toAssistantReminderNotFoundError(error: unknown): AssistantToolErrorResult | null { + return toAssistantNotFoundError(error, "Reminder not found with the given criteria."); } -function toAssistantNotificationNotFoundError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantNotificationNotFoundError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Notification not found with the given criteria." }; } @@ -1582,15 +1583,11 @@ function toAssistantNotificationNotFoundError( return null; } -function toAssistantNotificationReadError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantNotificationReadError(error: unknown): AssistantToolErrorResult | null { return toAssistantNotificationNotFoundError(error); } -function toAssistantNotificationDeletionError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantNotificationDeletionError(error: unknown): AssistantToolErrorResult | null { const notFound = toAssistantNotificationNotFoundError(error); if (notFound) { return notFound; @@ -1603,9 +1600,7 @@ function toAssistantNotificationDeletionError( return null; } -function toAssistantReminderCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantReminderCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "BAD_REQUEST") { return { error: "Reminder input is invalid." }; } @@ -1627,9 +1622,7 @@ function toAssistantReminderCreationError( return null; } -function toAssistantCommentResolveError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantCommentResolveError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { error: "Comment not found with the given criteria." }; } @@ -1641,9 +1634,7 @@ function toAssistantCommentResolveError( return null; } -function toAssistantCommentCreationError( - error: unknown, -): AssistantToolErrorResult | null { +function toAssistantCommentCreationError(error: unknown): AssistantToolErrorResult | null { if (error instanceof TRPCError && error.code === "BAD_REQUEST") { if (error.message.includes("at least 1 character")) { return { error: "Comment body is required." }; @@ -1778,18 +1769,20 @@ function getTrpcErrorMetadata(error: unknown): { shape?: { code?: unknown; message?: unknown; data?: { cause?: unknown } }; }; - const candidateCode = typeof candidate.code === "string" - ? candidate.code - : typeof candidate.data?.code === "string" - ? candidate.data.code - : typeof candidate.shape?.code === "string" - ? candidate.shape.code - : null; - const candidateMessage = typeof candidate.message === "string" - ? candidate.message - : typeof candidate.shape?.message === "string" - ? candidate.shape.message - : ""; + const candidateCode = + typeof candidate.code === "string" + ? candidate.code + : typeof candidate.data?.code === "string" + ? candidate.data.code + : typeof candidate.shape?.code === "string" + ? candidate.shape.code + : null; + const candidateMessage = + typeof candidate.message === "string" + ? candidate.message + : typeof candidate.shape?.message === "string" + ? candidate.shape.message + : ""; if (candidateCode && /^[A-Z_]+$/.test(candidateCode)) { return { @@ -1816,17 +1809,17 @@ function toAssistantNotificationCreationError( const trpcError = getTrpcErrorMetadata(error); if ( - context === "broadcast" - && trpcError?.code === "BAD_REQUEST" - && trpcError.message === "No recipients matched the broadcast target." + context === "broadcast" && + trpcError?.code === "BAD_REQUEST" && + trpcError.message === "No recipients matched the broadcast target." ) { return { error: "No recipients matched the broadcast target." }; } if ( - context === "broadcast" - && trpcError?.code === "BAD_REQUEST" - && trpcError.message === "Scheduled broadcasts with task metadata are not supported yet." + context === "broadcast" && + trpcError?.code === "BAD_REQUEST" && + trpcError.message === "Scheduled broadcasts with task metadata are not supported yet." ) { return { error: "Scheduled broadcasts with task metadata are not supported yet." }; } @@ -1869,7 +1862,10 @@ function toAssistantNotificationCreationError( return { error: "Sender user not found with the given criteria." }; } - if (context === "broadcast" && (errorText.includes("notificationbroadcast") || errorText.includes("broadcast"))) { + if ( + context === "broadcast" && + (errorText.includes("notificationbroadcast") || errorText.includes("broadcast")) + ) { return { error: "Broadcast not found with the given criteria." }; } @@ -1888,9 +1884,7 @@ function toAssistantNotificationCreationError( return { error: "Notification recipient user not found with the given criteria." }; } -function normalizeAssistantExecutionError( - error: unknown, -): AssistantToolErrorResult { +function normalizeAssistantExecutionError(error: unknown): AssistantToolErrorResult { if (error instanceof AssistantVisibleError) { return { error: error.message }; } @@ -1925,9 +1919,7 @@ function normalizeAssistantExecutionError( return { error: "The tool could not complete due to an unexpected error." }; } -function isAssistantToolErrorResult( - value: unknown, -): value is AssistantToolErrorResult { +function isAssistantToolErrorResult(value: unknown): value is AssistantToolErrorResult { return value !== null && typeof value === "object" && "error" in value; } @@ -1961,10 +1953,7 @@ async function resolveEntityOrAssistantError( } } -async function resolveProjectIdentifier( - ctx: ToolContext, - identifier: string, -) { +async function resolveProjectIdentifier(ctx: ToolContext, identifier: string) { const caller = createProjectCaller(createScopedCallerContext(ctx)); return resolveEntityOrAssistantError( () => caller.resolveByIdentifier({ identifier }), @@ -1972,10 +1961,7 @@ async function resolveProjectIdentifier( ); } -async function resolveResourceIdentifier( - ctx: ToolContext, - identifier: string, -) { +async function resolveResourceIdentifier(ctx: ToolContext, identifier: string) { const caller = createResourceCaller(createScopedCallerContext(ctx)); return resolveEntityOrAssistantError( () => caller.resolveByIdentifier({ identifier }), @@ -2010,62 +1996,65 @@ function sanitizeWebhookList(webhooks: T[] // ─── Tool Definitions ─────────────────────────────────────────────────────── -export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([ - // ── READ TOOLS ── - ...resourceReadToolDefinitions, - ...projectReadToolDefinitions, - ...advancedTimelineToolDefinitions, - ...allocationPlanningReadToolDefinitions, - ...vacationHolidayReadToolDefinitions, - ...vacationHolidayMutationToolDefinitions, - ...rolesAnalyticsReadToolDefinitions, - ...chargeabilityComputationReadToolDefinitions, - ...planningNavigationToolDefinitions, +export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess( + [ + // ── READ TOOLS ── + ...resourceReadToolDefinitions, + ...projectReadToolDefinitions, + ...advancedTimelineToolDefinitions, + ...allocationPlanningReadToolDefinitions, + ...vacationHolidayReadToolDefinitions, + ...vacationHolidayMutationToolDefinitions, + ...rolesAnalyticsReadToolDefinitions, + ...chargeabilityComputationReadToolDefinitions, + ...planningNavigationToolDefinitions, - // ── WRITE TOOLS ── - ...allocationPlanningMutationToolDefinitions, - ...resourceMutationToolDefinitions, - ...projectMutationToolDefinitions, - ...vacationEntitlementToolDefinitions, + // ── WRITE TOOLS ── + ...allocationPlanningMutationToolDefinitions, + ...resourceMutationToolDefinitions, + ...projectMutationToolDefinitions, + ...vacationEntitlementToolDefinitions, - // ── DEMAND / STAFFING ── - ...staffingDemandReadToolDefinitions, - ...staffingDemandMutationToolDefinitions, + // ── DEMAND / STAFFING ── + ...staffingDemandReadToolDefinitions, + ...staffingDemandMutationToolDefinitions, - // ── BLUEPRINT ── - ...blueprintsRateCardsToolDefinitions, + // ── BLUEPRINT ── + ...blueprintsRateCardsToolDefinitions, - // ── ESTIMATES ── - ...estimateReadToolDefinitions, - ...estimateMutationToolDefinitions, + // ── ESTIMATES ── + ...estimateReadToolDefinitions, + ...estimateMutationToolDefinitions, - // ── ROLES ── - ...rolesAnalyticsMutationToolDefinitions, + // ── ROLES ── + ...rolesAnalyticsMutationToolDefinitions, - // ── CLIENTS ── - ...clientMutationToolDefinitions, + // ── CLIENTS ── + ...clientMutationToolDefinitions, - // ── ADMIN / CONFIG READ TOOLS ── - ...countryReadmodelToolDefinitions, - ...countryMetroAdminToolDefinitions, - ...configReadmodelToolDefinitions, - ...userAdminToolDefinitions, - ...userSelfServiceToolDefinitions, - ...notificationInboxToolDefinitions, - ...dashboardInsightsReportsToolDefinitions, + // ── ADMIN / CONFIG READ TOOLS ── + ...countryReadmodelToolDefinitions, + ...countryMetroAdminToolDefinitions, + ...configReadmodelToolDefinitions, + ...userAdminToolDefinitions, + ...userSelfServiceToolDefinitions, + ...notificationInboxToolDefinitions, + ...dashboardInsightsReportsToolDefinitions, - // ── ORG UNIT MANAGEMENT ── - ...orgUnitMutationToolDefinitions, + // ── ORG UNIT MANAGEMENT ── + ...orgUnitMutationToolDefinitions, - // ── TASK MANAGEMENT ── - ...notificationTaskToolDefinitions, - ...commentReadToolDefinitions, - ...scenarioRateAnalysisToolDefinitions, - ...commentMutationToolDefinitions, - ...auditHistoryToolDefinitions, - ...importExportDispoToolDefinitions, - ...settingsAdminToolDefinitions, -], LEGACY_MONOLITHIC_TOOL_ACCESS); + // ── TASK MANAGEMENT ── + ...notificationTaskToolDefinitions, + ...commentReadToolDefinitions, + ...scenarioRateAnalysisToolDefinitions, + ...commentMutationToolDefinitions, + ...auditHistoryToolDefinitions, + ...importExportDispoToolDefinitions, + ...settingsAdminToolDefinitions, + ], + LEGACY_MONOLITHIC_TOOL_ACCESS, +); const TOOL_DEFINITIONS_BY_NAME = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]), @@ -2081,16 +2070,14 @@ type AssistantToolAccessFailure = message?: string; }; -function hasAssistantResourceOverviewAccess( - permissions: Set, -): boolean { - return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) - || permissions.has(PermissionKey.MANAGE_RESOURCES); +function hasAssistantResourceOverviewAccess(permissions: Set): boolean { + return ( + permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || + permissions.has(PermissionKey.MANAGE_RESOURCES) + ); } -function getAssistantToolAccessRequirements( - tool: ToolDef, -): ToolAccessRequirements | undefined { +function getAssistantToolAccessRequirements(tool: ToolDef): ToolAccessRequirements | undefined { return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name]; } @@ -2104,8 +2091,8 @@ function getAssistantToolAccessFailure( } if ( - access.allowedSystemRoles - && !access.allowedSystemRoles.includes(ctx.userRole as SystemRole) + access.allowedSystemRoles && + !access.allowedSystemRoles.includes(ctx.userRole as SystemRole) ) { return { type: "role" }; } @@ -2135,8 +2122,8 @@ function getAssistantToolAccessFailure( } if ( - access.requiresAdvancedAssistant - && !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS) + access.requiresAdvancedAssistant && + !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS) ) { return { type: "permission", @@ -2144,10 +2131,7 @@ function getAssistantToolAccessFailure( }; } - if ( - access.requiresResourceOverview - && !hasAssistantResourceOverviewAccess(ctx.permissions) - ) { + if (access.requiresResourceOverview && !hasAssistantResourceOverviewAccess(ctx.permissions)) { return { type: "permission", message: "Permission denied: you need resource overview access to perform this action.", @@ -2157,9 +2141,7 @@ function getAssistantToolAccessFailure( return null; } -function toAssistantToolAccessError( - failure: AssistantToolAccessFailure, -): AssistantVisibleError { +function toAssistantToolAccessError(failure: AssistantToolAccessFailure): AssistantVisibleError { if (failure.type === "role") { return new AssistantVisibleError("You do not have permission to perform this action."); }