1import { Inject, Injectable } from "@nestjs/common";
2import { PrismaService } from "../../common/prisma.service.js";
3import { AuditService } from "../../common/audit.service.js";
4import { EventBus } from "../../common/event-bus.js";
5import { ApiError } from "../../common/errors.js";
6import { randomToken, sha256hex } from "../../lib/crypto.js";
7import { assertRole } from "../../lib/authorize.js";
8import { EVENTS, API_KEY_SCOPES } from "../../constants.js";
9
10
15@Injectable()
16export class ApiKeysService {
17 constructor(
18 @Inject(PrismaService) private readonly prisma: PrismaService,
19 @Inject(AuditService) private readonly audit: AuditService,
20 @Inject(EventBus) private readonly events: EventBus,
21 ) {}
22
23 private async requireAdmin(workspaceId: string, userId: string | undefined) {
24 if (!userId) {
25 throw ApiError.forbidden("API key management requires user credentials (JWT).", [
26 { reason: "requires_user_credentials" },
27 ]);
28 }
29 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
30 }
31
32 async create(
33 workspaceId: string,
34 userId: string,
35 input: { name: string; scopes: string[]; rateLimit?: number; expiresAt?: string },
36 req: any,
37 ) {
38 await this.requireAdmin(workspaceId, userId);
39 const scopes = [...new Set(input.scopes)] as (typeof API_KEY_SCOPES)[number][];
40 for (const s of scopes) {
41 if (!API_KEY_SCOPES.includes(s)) {
42 throw ApiError.validation("Unknown scope.", [
43 { field: "scopes", issue: "unknown scope", received: s },
44 ]);
45 }
46 }
47 if (scopes.length === 0) scopes.push("READ");
48
49 const rawKey = `fk_live_${randomToken(32)}`;
50 const keyHash = sha256hex(rawKey);
51 const prefix = rawKey.slice(0, 14);
52 const dup = await this.prisma.apiKey.findFirst({
53 where: { workspaceId, name: input.name, revokedAt: null },
54 });
55 if (dup) {
56 throw new ApiError("CONFLICT", 409, "An active key with this name already exists.", [
57 { field: "name", issue: "already_exists" },
58 ]);
59 }
60 const key = await this.prisma.apiKey.create({
61 data: {
62 workspaceId,
63 name: input.name,
64 keyHash,
65 prefix,
66 scopes: scopes as never[],
67 rateLimit: input.rateLimit ?? null,
68 expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
69 createdById: userId,
70 },
71 });
72 await this.audit.audit(req, req?.auth, {
73 action: EVENTS.API_KEY_CREATED,
74 resourceType: "api_key",
75 resourceId: key.id,
76 workspaceId,
77 metadata: { name: input.name, scopes },
78 });
79 await this.events.publish(EVENTS.API_KEY_CREATED, {
80 workspaceId,
81 apiKeyId: key.id,
82 name: input.name,
83 scopes,
84 });
85 return {
86 id: key.id,
87 name: key.name,
88 key: rawKey,
89 prefix: key.prefix,
90 scopes: key.scopes,
91 rateLimit: key.rateLimit,
92 expiresAt: key.expiresAt,
93 createdById: key.createdById,
94 createdAt: key.createdAt,
95 };
96 }
97
98
99 async list(workspaceId: string, userId: string | undefined) {
100 await this.requireAdmin(workspaceId, userId);
101 const keys = await this.prisma.apiKey.findMany({
102 where: { workspaceId },
103 orderBy: { createdAt: "desc" },
104 select: {
105 id: true,
106 name: true,
107 prefix: true,
108 scopes: true,
109 rateLimit: true,
110 lastUsedAt: true,
111 expiresAt: true,
112 revokedAt: true,
113 createdById: true,
114 createdAt: true,
115 },
116 });
117 return { data: keys };
118 }
119
120 async update(
121 workspaceId: string,
122 apiKeyId: string,
123 userId: string | undefined,
124 patch: {
125 name?: string;
126 scopes?: string[];
127 active?: boolean;
128 rateLimit?: number | null;
129 expiresAt?: string | null;
130 },
131 req: any,
132 ) {
133 await this.requireAdmin(workspaceId, userId);
134 const key = await this.prisma.apiKey.findFirst({ where: { id: apiKeyId, workspaceId } });
135 if (!key) throw ApiError.notFound("API key not found.");
136 const scopes = patch.scopes
137 ? ([...new Set(patch.scopes)] as (typeof API_KEY_SCOPES)[number][])
138 : undefined;
139 if (scopes) {
140 for (const s of scopes) {
141 if (!API_KEY_SCOPES.includes(s)) {
142 throw ApiError.validation("Unknown scope.", [
143 { field: "scopes", issue: "unknown scope", received: s },
144 ]);
145 }
146 }
147 }
148 const updated = await this.prisma.apiKey.update({
149 where: { id: apiKeyId },
150 data: {
151 ...(patch.name !== undefined ? { name: patch.name } : {}),
152 ...(scopes ? { scopes: scopes as never[] } : {}),
153 ...(patch.rateLimit !== undefined ? { rateLimit: patch.rateLimit } : {}),
154 ...(patch.expiresAt !== undefined
155 ? { expiresAt: patch.expiresAt ? new Date(patch.expiresAt) : null }
156 : {}),
157 ...(patch.active !== undefined
158 ? { revokedAt: patch.active ? null : (key.revokedAt ?? new Date()) }
159 : {}),
160 },
161 });
162 await this.audit.audit(req, req?.auth, {
163 action: "api_key.updated",
164 resourceType: "api_key",
165 resourceId: apiKeyId,
166 workspaceId,
167 metadata: { fields: Object.keys(patch) },
168 });
169 return updated;
170 }
171
172
173 async revoke(workspaceId: string, apiKeyId: string, userId: string | undefined, req: any) {
174 await this.requireAdmin(workspaceId, userId);
175 const key = await this.prisma.apiKey.findFirst({ where: { id: apiKeyId, workspaceId } });
176 if (!key) throw ApiError.notFound("API key not found.");
177 if (!key.revokedAt) {
178 await this.prisma.apiKey.update({ where: { id: apiKeyId }, data: { revokedAt: new Date() } });
179
180
181
182
183 await this.audit.audit(req, req?.auth, {
184 action: "api_key.revoked",
185 resourceType: "api_key",
186 resourceId: apiKeyId,
187 workspaceId,
188 });
189 }
190 return { revoked: true };
191 }
192}
193