Merge SERVICE role into AGENT
Build & Push / Test (client) (push) Successful in 31s
Build & Push / Test (server) (push) Successful in 38s
Build & Push / Build Client (push) Successful in 1m17s
Build & Push / Build Server (push) Successful in 1m18s

Every AGENT now gets an auto-generated API key on creation, shown once
in a modal. AGENTs log in with password and authenticate to the API
with X-Api-Key. pre-push.sql defensively migrates any residual SERVICE
rows to AGENT before Prisma rewrites the enum. Goddard is no longer
baked into the seed — create agents via Admin → Users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 22:44:32 -04:00
parent a9ba74f1af
commit d8785a964d
18 changed files with 73 additions and 130 deletions
+2 -2
View File
@@ -5,8 +5,8 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/server/src/index.js",
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
+14
View File
@@ -0,0 +1,14 @@
-- Idempotent SQL applied BEFORE `prisma db push`.
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
) THEN
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
END IF;
END $$;
-1
View File
@@ -11,7 +11,6 @@ enum Role {
ADMIN
AGENT
USER
SERVICE
}
enum TicketStatus {
-20
View File
@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
const prisma = new PrismaClient();
@@ -20,25 +19,6 @@ async function main() {
},
});
// Goddard — n8n service account
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
await prisma.user.upsert({
where: { username: 'goddard' },
update: {},
create: {
username: 'goddard',
email: 'goddard@internal',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
role: 'SERVICE',
apiKey,
},
});
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
console.log('(This key is only displayed once on first seed — copy it now)\n');
// Sample CTI structure
const theWrightServer = await prisma.category.upsert({
where: { name: 'TheWrightServer' },
+2 -2
View File
@@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user || user.role !== 'SERVICE') {
if (!user) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = { id: user.id, role: user.role, username: user.username };
@@ -48,7 +48,7 @@ export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction
next();
};
// Blocks USER role — allows ADMIN, AGENT, SERVICE
// Blocks USER role — allows ADMIN and AGENT
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' });
-20
View File
@@ -51,24 +51,4 @@ describe('authService.login', () => {
});
});
it('rejects SERVICE role from password login', async () => {
const password = 'svc-pw';
prismaMock.user.findUnique.mockResolvedValue({
id: 'svc',
username: 'goddard',
email: 'g@x.io',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(password, 4),
role: 'SERVICE',
apiKey: 'sk_xyz',
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
status: 401,
message: expect.stringMatching(/API key/i),
});
});
});
-4
View File
@@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) {
throw new HttpError(401, 'Invalid credentials');
}
if (user.role === 'SERVICE') {
throw new HttpError(401, 'Service accounts must authenticate via API key');
}
const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username },
process.env.JWT_SECRET!,
+10 -9
View File
@@ -17,15 +17,15 @@ const stubUser = {
};
describe('userService.createUser', () => {
it('hashes the password and omits apiKey for non-SERVICE roles', async () => {
prismaMock.user.create.mockResolvedValue(stubUser);
it('hashes the password and omits apiKey for ADMIN and USER roles', async () => {
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' });
await createUser({
username: 'bob',
email: 'b@x.io',
displayName: 'Bob',
password: 'hunter2!',
role: 'AGENT',
role: 'USER',
});
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
@@ -34,14 +34,15 @@ describe('userService.createUser', () => {
expect(call.data.apiKey).toBeUndefined();
});
it('assigns an apiKey for SERVICE role', async () => {
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' });
it('assigns an apiKey for AGENT role', async () => {
prismaMock.user.create.mockResolvedValue(stubUser);
await createUser({
username: 'svc',
email: 's@x.io',
displayName: 'Svc',
role: 'SERVICE',
username: 'agent',
email: 'a@x.io',
displayName: 'Agent',
password: 'hunter2!',
role: 'AGENT',
});
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
+9 -6
View File
@@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) {
}
export async function createUser(data: CreateUserInput) {
const passwordHash = data.password
? await bcrypt.hash(data.password, 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
const passwordHash = await bcrypt.hash(data.password, 12);
const apiKey =
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
data.role === 'AGENT' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
return prisma.user.create({
data: {
@@ -68,8 +66,13 @@ export async function updateUser(id: string, data: UpdateUserInput) {
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
if (data.role) {
update.role = data.role;
if (data.role === 'SERVICE' && !update.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
if (data.role === 'AGENT') {
const existing = await prisma.user.findUnique({ where: { id }, select: { apiKey: true } });
if (!existing?.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
}
} else {
update.apiKey = null;
}
}
if (data.regenerateApiKey) {