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
@@ -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) {