Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import crypto from 'node:crypto';
2import type { Kysely } from 'kysely';
3import { inject } from '../../iocContainer/index.js';
4import { type CombinedPg } from '../combinedDbTypes.js';
5
6export interface ApiKeyMetadata {
7 name: string;
8 description: string;
9}
10
11export interface ApiKeyWithMetadata {
12 key: string;
13 metadata: ApiKeyMetadata;
14}
15
16export interface ApiKeyRecord {
17 id: string;
18 orgId: string;
19 keyHash: string;
20 name: string;
21 description: string | null;
22 isActive: boolean;
23 createdAt: Date;
24 updatedAt: Date;
25 lastUsedAt: Date | null;
26 createdBy: string | null;
27}
28
29class ApiKeyService {
30 constructor(private readonly db: Kysely<CombinedPg>) {}
31
32 /**
33 * Generates a secure random API key
34 */
35 private generateApiKey(): string {
36 return crypto.randomBytes(32).toString('hex');
37 }
38
39 /**
40 * Hashes an API key for secure storage
41 */
42 private hashApiKey(apiKey: string): string {
43 return crypto.createHash('sha256').update(apiKey).digest('hex');
44 }
45
46 /**
47 * Creates a new API key for an organization
48 */
49 async createApiKey(
50 orgId: string,
51 name: string,
52 description: string | null,
53 createdBy: string | null
54 ): Promise<{ apiKey: string; record: ApiKeyRecord }> {
55 const apiKey = this.generateApiKey();
56 const keyHash = this.hashApiKey(apiKey);
57
58 // Deactivate any existing active keys for this org
59 await this.deactivateAllKeysForOrg(orgId);
60
61 const result = await this.db
62 .insertInto('public.api_keys')
63 .values({
64 org_id: orgId,
65 key_hash: keyHash,
66 name,
67 description,
68 is_active: true,
69 created_by: createdBy,
70 })
71 .returningAll()
72 .executeTakeFirstOrThrow();
73
74 return {
75 apiKey,
76 record: this.mapDbRecordToApiKeyRecord(result),
77 };
78 }
79
80 /**
81 * Rotates (creates new and deactivates old) API key for an organization
82 */
83 async rotateApiKey(
84 orgId: string,
85 name: string,
86 description: string | null,
87 createdBy: string | null
88 ): Promise<{ apiKey: string; record: ApiKeyRecord }> {
89 return this.createApiKey(orgId, name, description, createdBy);
90 }
91
92 /**
93 * Gets the active API key for an organization
94 */
95 async getActiveApiKeyForOrg(orgId: string): Promise<ApiKeyRecord | null> {
96 const result = await this.db
97 .selectFrom('public.api_keys')
98 .selectAll()
99 .where('org_id', '=', orgId)
100 .where('is_active', '=', true)
101 .executeTakeFirst();
102
103 return result ? this.mapDbRecordToApiKeyRecord(result) : null;
104 }
105
106 /**
107 * Gets all API keys for an organization
108 */
109 async getApiKeysForOrg(orgId: string): Promise<ApiKeyRecord[]> {
110 const results = await this.db
111 .selectFrom('public.api_keys')
112 .selectAll()
113 .where('org_id', '=', orgId)
114 .orderBy('created_at', 'desc')
115 .execute();
116
117 return results.map(this.mapDbRecordToApiKeyRecord);
118 }
119
120 /**
121 * Validates an API key and returns the associated org ID
122 */
123 async validateApiKey(apiKey: string): Promise<string | null> {
124 const keyHash = this.hashApiKey(apiKey);
125
126 const result = await this.db
127 .selectFrom('public.api_keys')
128 .select(['org_id', 'last_used_at'])
129 .where('key_hash', '=', keyHash)
130 .where('is_active', '=', true)
131 .executeTakeFirst();
132
133 if (!result) {
134 return null;
135 }
136
137 // Update last used timestamp
138 await this.db
139 .updateTable('public.api_keys')
140 .set({ last_used_at: new Date() })
141 .where('key_hash', '=', keyHash)
142 .execute();
143
144 return result.org_id;
145 }
146
147 /**
148 * Deactivates a specific API key
149 */
150 async deactivateApiKey(keyId: string, orgId: string): Promise<boolean> {
151 const result = await this.db
152 .updateTable('public.api_keys')
153 .set({ is_active: false })
154 .where('id', '=', keyId)
155 .where('org_id', '=', orgId)
156 .execute();
157
158 return result.length > 0;
159 }
160
161 /**
162 * Deactivates all API keys for an organization
163 */
164 async deactivateAllKeysForOrg(orgId: string): Promise<void> {
165 await this.db
166 .updateTable('public.api_keys')
167 .set({ is_active: false })
168 .where('org_id', '=', orgId)
169 .execute();
170 }
171
172 /**
173 * Deletes a specific API key
174 */
175 async deleteApiKey(keyId: string, orgId: string): Promise<boolean> {
176 const result = await this.db
177 .deleteFrom('public.api_keys')
178 .where('id', '=', keyId)
179 .where('org_id', '=', orgId)
180 .execute();
181
182 return result.length > 0;
183 }
184 /**
185 * Gets the org ID from an activated API key
186 */
187 async getOrgIdFromActivatedKey(apiKey: string): Promise<string | null> {
188 const keyHash = this.hashApiKey(apiKey);
189 const result = await this.db.selectFrom('public.api_keys').select('org_id').where('key_hash', '=', keyHash).where('is_active', '=', true).executeTakeFirst();
190 if (!result) {
191 return null;
192 }
193 return result.org_id;
194 }
195
196 /**
197 * Maps database record to ApiKeyRecord
198 */
199 private mapDbRecordToApiKeyRecord(record: any): ApiKeyRecord {
200 return {
201 id: record.id,
202 orgId: record.org_id,
203 keyHash: record.key_hash,
204 name: record.name,
205 description: record.description,
206 isActive: record.is_active,
207 createdAt: record.created_at,
208 updatedAt: record.updated_at,
209 lastUsedAt: record.last_used_at,
210 createdBy: record.created_by,
211 };
212 }
213}
214
215export default inject(['KyselyPg'], ApiKeyService);
216export type { ApiKeyService };