@kognitivedev/workspace-auth 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crypto.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+
3
+ export type EncryptedSecret = {
4
+ encryptedKey: string;
5
+ iv: string;
6
+ authTag: string;
7
+ };
8
+
9
+ export function createRawApiKey() {
10
+ return `kog_${randomBytes(16).toString("hex")}`;
11
+ }
12
+
13
+ export function getApiKeyPrefix(rawKey: string) {
14
+ return rawKey.slice(0, 8);
15
+ }
16
+
17
+ export function hashApiKey(rawKey: string) {
18
+ return createHash("sha256").update(rawKey).digest("hex");
19
+ }
20
+
21
+ function getEncryptionKey(secret = process.env.KOGNITIVE_MANAGED_CREDENTIAL_SECRET) {
22
+ if (!secret || Buffer.byteLength(secret, "utf8") < 32) {
23
+ throw new Error("KOGNITIVE_MANAGED_CREDENTIAL_SECRET must be set to a stable 32+ byte secret");
24
+ }
25
+ return createHash("sha256").update(secret).digest();
26
+ }
27
+
28
+ export function encryptManagedSecret(rawSecret: string, secret?: string): EncryptedSecret {
29
+ const iv = randomBytes(12);
30
+ const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(secret), iv);
31
+ const encrypted = Buffer.concat([cipher.update(rawSecret, "utf8"), cipher.final()]);
32
+ return {
33
+ encryptedKey: encrypted.toString("base64"),
34
+ iv: iv.toString("base64"),
35
+ authTag: cipher.getAuthTag().toString("base64"),
36
+ };
37
+ }
38
+
39
+ export function decryptManagedSecret(encrypted: EncryptedSecret, secret?: string): string {
40
+ const decipher = createDecipheriv(
41
+ "aes-256-gcm",
42
+ getEncryptionKey(secret),
43
+ Buffer.from(encrypted.iv, "base64"),
44
+ );
45
+ decipher.setAuthTag(Buffer.from(encrypted.authTag, "base64"));
46
+ return Buffer.concat([
47
+ decipher.update(Buffer.from(encrypted.encryptedKey, "base64")),
48
+ decipher.final(),
49
+ ]).toString("utf8");
50
+ }
package/src/db.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema";
4
+
5
+ const globalForWorkspaceAuthDb = globalThis as typeof globalThis & {
6
+ workspaceAuthDbClient?: ReturnType<typeof postgres>;
7
+ workspaceAuthDb?: ReturnType<typeof drizzle<typeof schema>>;
8
+ };
9
+
10
+ function readPositiveInteger(value: string | undefined, fallback: number) {
11
+ if (!value) return fallback;
12
+ const parsed = Number.parseInt(value, 10);
13
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
14
+ }
15
+
16
+ function shouldPrepareStatements(connectionString: string) {
17
+ const explicit = process.env.DATABASE_PREPARE_STATEMENTS;
18
+ if (explicit) {
19
+ return explicit !== "0" && explicit.toLowerCase() !== "false";
20
+ }
21
+
22
+ const poolingMode = process.env.DATABASE_POOL_MODE ?? process.env.PGBOUNCER_POOL_MODE;
23
+ if (poolingMode?.toLowerCase() === "transaction") return false;
24
+ return !connectionString.toLowerCase().includes("pgbouncer");
25
+ }
26
+
27
+ export function getWorkspaceAuthDb() {
28
+ const connectionString = process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/cognitive_layer";
29
+ const client =
30
+ globalForWorkspaceAuthDb.workspaceAuthDbClient ??
31
+ postgres(connectionString, {
32
+ max: readPositiveInteger(process.env.DATABASE_POOL_MAX ?? process.env.POSTGRES_POOL_MAX, 20),
33
+ idle_timeout: readPositiveInteger(process.env.DATABASE_IDLE_TIMEOUT_SECONDS, 30),
34
+ connect_timeout: readPositiveInteger(process.env.DATABASE_CONNECT_TIMEOUT_SECONDS, 10),
35
+ prepare: shouldPrepareStatements(connectionString),
36
+ });
37
+ const db = globalForWorkspaceAuthDb.workspaceAuthDb ?? drizzle(client, { schema });
38
+
39
+ globalForWorkspaceAuthDb.workspaceAuthDbClient = client;
40
+ globalForWorkspaceAuthDb.workspaceAuthDb = db;
41
+
42
+ return db;
43
+ }
44
+
45
+ export type WorkspaceAuthDb = ReturnType<typeof getWorkspaceAuthDb>;
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ export {
2
+ ACTIVE_PROJECT_ID_COOKIE,
3
+ ACTIVE_PROJECT_SLUG_COOKIE,
4
+ KOGNITIV_APPOINTMENTS_MANAGED_KEY_NAME,
5
+ KOGNITIV_APPOINTMENTS_PRODUCT,
6
+ KOGNITIV_VOICE_MANAGED_KEY_NAME,
7
+ KOGNITIV_VOICE_PRODUCT,
8
+ createWorkspaceProject,
9
+ ensureManagedProjectApiKey,
10
+ ensureOrganizationForClerk,
11
+ getOwnedProject,
12
+ getProductSetupState,
13
+ hasManagedProjectApiKey,
14
+ listOrganizationProjects,
15
+ resolveActiveProject,
16
+ resolveProductSetupWorkspace,
17
+ slugifyWorkspaceValue,
18
+ updateProductSetupState,
19
+ type ProductSetupStateValue,
20
+ } from "./workspace";
21
+ export {
22
+ createRawApiKey,
23
+ decryptManagedSecret,
24
+ encryptManagedSecret,
25
+ getApiKeyPrefix,
26
+ hashApiKey,
27
+ type EncryptedSecret,
28
+ } from "./crypto";
29
+ export { getWorkspaceAuthDb, type WorkspaceAuthDb } from "./db";
30
+ export {
31
+ apiKeys,
32
+ managedProjectApiKeys,
33
+ organizations,
34
+ productSetupStates,
35
+ projects,
36
+ } from "./schema";
package/src/schema.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
3
+
4
+ export const organizations = pgTable("organizations", {
5
+ id: uuid("id").defaultRandom().primaryKey(),
6
+ clerkOrgId: text("clerk_org_id").notNull(),
7
+ name: text("name").notNull(),
8
+ slug: text("slug").notNull(),
9
+ createdAt: timestamp("created_at").defaultNow().notNull(),
10
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
11
+ }, (table) => ({
12
+ clerkOrgIdIdx: uniqueIndex("organizations_clerk_org_id_idx").on(table.clerkOrgId),
13
+ slugIdx: uniqueIndex("organizations_slug_idx").on(table.slug),
14
+ }));
15
+
16
+ export const projects = pgTable("projects", {
17
+ id: uuid("id").defaultRandom().primaryKey(),
18
+ organizationId: uuid("organization_id").notNull().references(() => organizations.id),
19
+ name: text("name").notNull(),
20
+ slug: text("slug").notNull(),
21
+ description: text("description"),
22
+ isActive: boolean("is_active").default(true).notNull(),
23
+ createdAt: timestamp("created_at").defaultNow().notNull(),
24
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
25
+ }, (table) => ({
26
+ orgSlugIdx: uniqueIndex("projects_org_slug_idx").on(table.organizationId, table.slug),
27
+ orgIdx: index("projects_org_idx").on(table.organizationId),
28
+ }));
29
+
30
+ export const apiKeys = pgTable("api_keys", {
31
+ id: uuid("id").defaultRandom().primaryKey(),
32
+ projectId: uuid("project_id").notNull().references(() => projects.id),
33
+ keyHash: text("key_hash").notNull(),
34
+ keyPrefix: text("key_prefix").notNull(),
35
+ name: text("name"),
36
+ lastUsedAt: timestamp("last_used_at"),
37
+ expiresAt: timestamp("expires_at"),
38
+ isActive: boolean("is_active").default(true).notNull(),
39
+ createdAt: timestamp("created_at").defaultNow().notNull(),
40
+ }, (table) => ({
41
+ keyHashIdx: uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
42
+ projectIdx: index("api_keys_project_idx").on(table.projectId),
43
+ }));
44
+
45
+ export const managedProjectApiKeys = pgTable("managed_project_api_keys", {
46
+ id: uuid("id").defaultRandom().primaryKey(),
47
+ projectId: uuid("project_id").notNull().references(() => projects.id),
48
+ apiKeyId: uuid("api_key_id").notNull().references(() => apiKeys.id),
49
+ product: text("product").notNull(),
50
+ encryptedKey: text("encrypted_key").notNull(),
51
+ iv: text("iv").notNull(),
52
+ authTag: text("auth_tag").notNull(),
53
+ keyPrefix: text("key_prefix").notNull(),
54
+ createdAt: timestamp("created_at").defaultNow().notNull(),
55
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
56
+ lastUsedAt: timestamp("last_used_at"),
57
+ revokedAt: timestamp("revoked_at"),
58
+ }, (table) => ({
59
+ projectProductIdx: index("managed_project_api_keys_project_product_idx").on(table.projectId, table.product),
60
+ apiKeyIdx: uniqueIndex("managed_project_api_keys_api_key_idx").on(table.apiKeyId),
61
+ activeProjectProductIdx: uniqueIndex("managed_project_api_keys_active_project_product_idx")
62
+ .on(table.projectId, table.product)
63
+ .where(sql`${table.revokedAt} IS NULL`),
64
+ }));
65
+
66
+ export const productSetupStates = pgTable("product_setup_states", {
67
+ id: uuid("id").defaultRandom().primaryKey(),
68
+ organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
69
+ product: text("product").notNull(),
70
+ state: text("state").default("not_started").notNull(),
71
+ projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
72
+ setupSessionId: text("setup_session_id").notNull(),
73
+ metadata: jsonb("metadata").default({}).notNull(),
74
+ startedAt: timestamp("started_at"),
75
+ completedAt: timestamp("completed_at"),
76
+ createdAt: timestamp("created_at").defaultNow().notNull(),
77
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
78
+ }, (table) => ({
79
+ orgProductIdx: uniqueIndex("product_setup_states_org_product_idx").on(table.organizationId, table.product),
80
+ projectIdx: index("product_setup_states_project_idx").on(table.projectId),
81
+ sessionIdx: uniqueIndex("product_setup_states_setup_session_idx").on(table.setupSessionId),
82
+ }));
@@ -0,0 +1,405 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { and, asc, eq, isNull } from "drizzle-orm";
3
+ import {
4
+ createRawApiKey,
5
+ decryptManagedSecret,
6
+ encryptManagedSecret,
7
+ getApiKeyPrefix,
8
+ hashApiKey,
9
+ } from "./crypto";
10
+ import { getWorkspaceAuthDb, type WorkspaceAuthDb } from "./db";
11
+ import { apiKeys, managedProjectApiKeys, organizations, productSetupStates, projects } from "./schema";
12
+
13
+ export const ACTIVE_PROJECT_ID_COOKIE = "active_project_id";
14
+ export const ACTIVE_PROJECT_SLUG_COOKIE = "active_project_slug";
15
+ export const KOGNITIV_VOICE_PRODUCT = "kognitiv-voice";
16
+ export const KOGNITIV_VOICE_MANAGED_KEY_NAME = "Managed: Kognitiv Voice";
17
+ export const KOGNITIV_APPOINTMENTS_PRODUCT = "kognitiv-appointments";
18
+ export const KOGNITIV_APPOINTMENTS_MANAGED_KEY_NAME = "Managed: Kognitiv Appointments";
19
+
20
+ type Db = WorkspaceAuthDb;
21
+ export type ProductSetupStateValue = "not_started" | "in_progress" | "completed";
22
+ type ManagedApiKeyRow = {
23
+ managedId: string;
24
+ encryptedKey: string;
25
+ iv: string;
26
+ authTag: string;
27
+ apiKeyId: string;
28
+ isActive: boolean;
29
+ expiresAt: Date | null;
30
+ keyPrefix: string;
31
+ };
32
+
33
+ const productSetupStateValues = new Set<ProductSetupStateValue>(["not_started", "in_progress", "completed"]);
34
+
35
+ function dbOrDefault(db?: Db) {
36
+ return db ?? getWorkspaceAuthDb();
37
+ }
38
+
39
+ function isUniqueConstraintError(error: unknown, constraintName: string) {
40
+ const candidate = error as {
41
+ code?: string;
42
+ constraint?: string;
43
+ constraint_name?: string;
44
+ message?: string;
45
+ } | null;
46
+ return Boolean(
47
+ candidate
48
+ && candidate.code === "23505"
49
+ && (
50
+ candidate.constraint === constraintName
51
+ || candidate.constraint_name === constraintName
52
+ || candidate.message?.includes(constraintName)
53
+ ),
54
+ );
55
+ }
56
+
57
+ function isManagedKeyUsable(row: ManagedApiKeyRow | null, now = new Date()) {
58
+ return Boolean(row?.isActive && (!row.expiresAt || row.expiresAt > now));
59
+ }
60
+
61
+ function normalizeProductSetupState(value: unknown, fallback: ProductSetupStateValue = "not_started"): ProductSetupStateValue {
62
+ return typeof value === "string" && productSetupStateValues.has(value as ProductSetupStateValue)
63
+ ? value as ProductSetupStateValue
64
+ : fallback;
65
+ }
66
+
67
+ function setupSessionId(product: string, organizationId: string) {
68
+ return `${slugifyWorkspaceValue(product, "product")}-setup-${organizationId}-${randomUUID()}`;
69
+ }
70
+
71
+ function setupMetadata(value: unknown) {
72
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
73
+ }
74
+
75
+ function toProductSetupState(row: typeof productSetupStates.$inferSelect) {
76
+ return {
77
+ ...row,
78
+ state: normalizeProductSetupState(row.state),
79
+ metadata: setupMetadata(row.metadata),
80
+ };
81
+ }
82
+
83
+ async function findUnrevokedManagedProjectApiKey(db: Db, input: {
84
+ projectId: string;
85
+ product: string;
86
+ }): Promise<ManagedApiKeyRow | null> {
87
+ return db
88
+ .select({
89
+ managedId: managedProjectApiKeys.id,
90
+ encryptedKey: managedProjectApiKeys.encryptedKey,
91
+ iv: managedProjectApiKeys.iv,
92
+ authTag: managedProjectApiKeys.authTag,
93
+ apiKeyId: apiKeys.id,
94
+ isActive: apiKeys.isActive,
95
+ expiresAt: apiKeys.expiresAt,
96
+ keyPrefix: managedProjectApiKeys.keyPrefix,
97
+ })
98
+ .from(managedProjectApiKeys)
99
+ .innerJoin(apiKeys, eq(managedProjectApiKeys.apiKeyId, apiKeys.id))
100
+ .where(and(
101
+ eq(managedProjectApiKeys.projectId, input.projectId),
102
+ eq(managedProjectApiKeys.product, input.product),
103
+ isNull(managedProjectApiKeys.revokedAt),
104
+ ))
105
+ .orderBy(asc(managedProjectApiKeys.createdAt))
106
+ .limit(1)
107
+ .then((rows) => rows[0] ?? null);
108
+ }
109
+
110
+ async function useExistingManagedProjectApiKey(db: Db, existing: ManagedApiKeyRow) {
111
+ const rawKey = decryptManagedSecret(existing);
112
+ const now = new Date();
113
+ await Promise.all([
114
+ db.update(apiKeys).set({ lastUsedAt: now }).where(eq(apiKeys.id, existing.apiKeyId)),
115
+ db.update(managedProjectApiKeys).set({ lastUsedAt: now, updatedAt: now }).where(eq(managedProjectApiKeys.id, existing.managedId)),
116
+ ]);
117
+ return { key: rawKey, keyPrefix: existing.keyPrefix, action: "used" as const };
118
+ }
119
+
120
+ async function revokeManagedProjectApiKey(db: Db, managedId: string) {
121
+ const now = new Date();
122
+ await db
123
+ .update(managedProjectApiKeys)
124
+ .set({ revokedAt: now, updatedAt: now })
125
+ .where(and(eq(managedProjectApiKeys.id, managedId), isNull(managedProjectApiKeys.revokedAt)));
126
+ }
127
+
128
+ export function slugifyWorkspaceValue(value: string, fallback = "project") {
129
+ const slug = String(value || "")
130
+ .trim()
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, "-")
133
+ .replace(/^-+|-+$/g, "");
134
+ return slug || fallback;
135
+ }
136
+
137
+ export async function ensureOrganizationForClerk(input: {
138
+ clerkOrgId: string;
139
+ name?: string | null;
140
+ slug?: string | null;
141
+ db?: Db;
142
+ }) {
143
+ const db = dbOrDefault(input.db);
144
+ const existing = await db
145
+ .select()
146
+ .from(organizations)
147
+ .where(eq(organizations.clerkOrgId, input.clerkOrgId))
148
+ .limit(1)
149
+ .then((rows) => rows[0] ?? null);
150
+ if (existing) return existing;
151
+
152
+ const name = input.name?.trim() || input.clerkOrgId;
153
+ const slug = slugifyWorkspaceValue(input.slug || name || input.clerkOrgId, input.clerkOrgId.toLowerCase());
154
+ const inserted = await db
155
+ .insert(organizations)
156
+ .values({ clerkOrgId: input.clerkOrgId, name, slug })
157
+ .returning();
158
+ return inserted[0];
159
+ }
160
+
161
+ export async function listOrganizationProjects(organizationId: string, db?: Db) {
162
+ return dbOrDefault(db)
163
+ .select()
164
+ .from(projects)
165
+ .where(eq(projects.organizationId, organizationId))
166
+ .orderBy(asc(projects.createdAt), asc(projects.name));
167
+ }
168
+
169
+ export async function getOwnedProject(input: { organizationId: string; projectId: string; db?: Db }) {
170
+ return dbOrDefault(input.db)
171
+ .select()
172
+ .from(projects)
173
+ .where(and(eq(projects.id, input.projectId), eq(projects.organizationId, input.organizationId)))
174
+ .limit(1)
175
+ .then((rows) => rows[0] ?? null);
176
+ }
177
+
178
+ export async function resolveActiveProject(input: {
179
+ organizationId: string;
180
+ activeProjectId?: string | null;
181
+ db?: Db;
182
+ }) {
183
+ const orgProjects = await listOrganizationProjects(input.organizationId, input.db);
184
+ if (orgProjects.length === 0) return { project: null, projects: orgProjects };
185
+ const activeProject = input.activeProjectId
186
+ ? orgProjects.find((project) => project.id === input.activeProjectId) ?? null
187
+ : null;
188
+ return { project: activeProject ?? orgProjects[0], projects: orgProjects };
189
+ }
190
+
191
+ export async function getProductSetupState(input: {
192
+ organizationId: string;
193
+ product: string;
194
+ db?: Db;
195
+ }) {
196
+ const db = dbOrDefault(input.db);
197
+ const product = input.product.trim();
198
+ if (!product) throw new Error("Product is required");
199
+
200
+ const existing = await db
201
+ .select()
202
+ .from(productSetupStates)
203
+ .where(and(
204
+ eq(productSetupStates.organizationId, input.organizationId),
205
+ eq(productSetupStates.product, product),
206
+ ))
207
+ .limit(1)
208
+ .then((rows) => rows[0] ?? null);
209
+ if (existing) return toProductSetupState(existing);
210
+
211
+ try {
212
+ const inserted = await db
213
+ .insert(productSetupStates)
214
+ .values({
215
+ organizationId: input.organizationId,
216
+ product,
217
+ state: "not_started",
218
+ setupSessionId: setupSessionId(product, input.organizationId),
219
+ })
220
+ .returning();
221
+ return toProductSetupState(inserted[0]);
222
+ } catch (error) {
223
+ if (!isUniqueConstraintError(error, "product_setup_states_org_product_idx")) throw error;
224
+ const row = await db
225
+ .select()
226
+ .from(productSetupStates)
227
+ .where(and(
228
+ eq(productSetupStates.organizationId, input.organizationId),
229
+ eq(productSetupStates.product, product),
230
+ ))
231
+ .limit(1)
232
+ .then((rows) => rows[0] ?? null);
233
+ if (!row) throw error;
234
+ return toProductSetupState(row);
235
+ }
236
+ }
237
+
238
+ export async function updateProductSetupState(input: {
239
+ organizationId: string;
240
+ product: string;
241
+ state?: ProductSetupStateValue | string | null;
242
+ projectId?: string | null;
243
+ metadata?: Record<string, unknown> | null;
244
+ db?: Db;
245
+ }) {
246
+ const db = dbOrDefault(input.db);
247
+ const existing = await getProductSetupState(input);
248
+ const nextState = input.state !== undefined && input.state !== null
249
+ ? normalizeProductSetupState(input.state, existing.state)
250
+ : input.projectId !== undefined && existing.state === "not_started"
251
+ ? "in_progress"
252
+ : existing.state;
253
+ const now = new Date();
254
+ const nextMetadata = input.metadata
255
+ ? { ...existing.metadata, ...input.metadata }
256
+ : existing.metadata;
257
+ const updates = {
258
+ state: nextState,
259
+ ...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
260
+ metadata: nextMetadata,
261
+ ...(nextState !== "not_started" && !existing.startedAt ? { startedAt: now } : {}),
262
+ ...(nextState === "completed" && !existing.completedAt ? { completedAt: now } : {}),
263
+ updatedAt: now,
264
+ };
265
+ const updated = await db
266
+ .update(productSetupStates)
267
+ .set(updates)
268
+ .where(and(
269
+ eq(productSetupStates.organizationId, input.organizationId),
270
+ eq(productSetupStates.product, input.product),
271
+ ))
272
+ .returning();
273
+ return toProductSetupState(updated[0]);
274
+ }
275
+
276
+ export async function resolveProductSetupWorkspace(input: {
277
+ organizationId: string;
278
+ product: string;
279
+ db?: Db;
280
+ }) {
281
+ const db = dbOrDefault(input.db);
282
+ const [setup, orgProjects] = await Promise.all([
283
+ getProductSetupState({ organizationId: input.organizationId, product: input.product, db }),
284
+ listOrganizationProjects(input.organizationId, db),
285
+ ]);
286
+ const project = setup.projectId
287
+ ? orgProjects.find((candidate) => candidate.id === setup.projectId) ?? null
288
+ : null;
289
+ return { setup, project, projects: orgProjects };
290
+ }
291
+
292
+ export async function createWorkspaceProject(input: {
293
+ organizationId: string;
294
+ name: string;
295
+ slug?: string | null;
296
+ description?: string | null;
297
+ db?: Db;
298
+ }) {
299
+ const name = input.name.trim();
300
+ if (!name) throw new Error("Project name is required");
301
+ const slug = slugifyWorkspaceValue(input.slug || name);
302
+ const inserted = await dbOrDefault(input.db)
303
+ .insert(projects)
304
+ .values({
305
+ organizationId: input.organizationId,
306
+ name,
307
+ slug,
308
+ description: input.description?.trim() || null,
309
+ })
310
+ .returning();
311
+ return inserted[0];
312
+ }
313
+
314
+ export async function hasManagedProjectApiKey(input: {
315
+ projectId: string;
316
+ product: string;
317
+ db?: Db;
318
+ }) {
319
+ const row = await dbOrDefault(input.db)
320
+ .select({ id: managedProjectApiKeys.id })
321
+ .from(managedProjectApiKeys)
322
+ .innerJoin(apiKeys, eq(managedProjectApiKeys.apiKeyId, apiKeys.id))
323
+ .where(and(
324
+ eq(managedProjectApiKeys.projectId, input.projectId),
325
+ eq(managedProjectApiKeys.product, input.product),
326
+ isNull(managedProjectApiKeys.revokedAt),
327
+ eq(apiKeys.isActive, true),
328
+ ))
329
+ .limit(1)
330
+ .then((rows) => rows[0] ?? null);
331
+ return Boolean(row);
332
+ }
333
+
334
+ export async function ensureManagedProjectApiKey(input: {
335
+ projectId: string;
336
+ product: string;
337
+ name?: string | null;
338
+ db?: Db;
339
+ }) {
340
+ const db = dbOrDefault(input.db);
341
+ for (let attempt = 0; attempt < 3; attempt += 1) {
342
+ const existing = await findUnrevokedManagedProjectApiKey(db, input);
343
+ if (existing && isManagedKeyUsable(existing)) {
344
+ return useExistingManagedProjectApiKey(db, existing);
345
+ }
346
+
347
+ if (existing) {
348
+ await revokeManagedProjectApiKey(db, existing.managedId);
349
+ continue;
350
+ }
351
+
352
+ const rawKey = createRawApiKey();
353
+ const keyPrefix = getApiKeyPrefix(rawKey);
354
+ const encrypted = encryptManagedSecret(rawKey);
355
+
356
+ try {
357
+ const inserted = await db.transaction(async (tx) => {
358
+ const [apiKey] = await tx
359
+ .insert(apiKeys)
360
+ .values({
361
+ projectId: input.projectId,
362
+ keyHash: hashApiKey(rawKey),
363
+ keyPrefix,
364
+ name: input.name?.trim() || null,
365
+ })
366
+ .returning();
367
+
368
+ const [managed] = await tx
369
+ .insert(managedProjectApiKeys)
370
+ .values({
371
+ projectId: input.projectId,
372
+ apiKeyId: apiKey.id,
373
+ product: input.product,
374
+ encryptedKey: encrypted.encryptedKey,
375
+ iv: encrypted.iv,
376
+ authTag: encrypted.authTag,
377
+ keyPrefix,
378
+ })
379
+ .returning();
380
+
381
+ return { apiKey, managed };
382
+ });
383
+
384
+ return { key: rawKey, keyPrefix: inserted.managed.keyPrefix, action: "created" as const };
385
+ } catch (error) {
386
+ if (!isUniqueConstraintError(error, "managed_project_api_keys_active_project_product_idx")) {
387
+ throw error;
388
+ }
389
+
390
+ const raced = await findUnrevokedManagedProjectApiKey(db, input);
391
+ if (raced && isManagedKeyUsable(raced)) {
392
+ return useExistingManagedProjectApiKey(db, raced);
393
+ }
394
+ if (raced) {
395
+ await revokeManagedProjectApiKey(db, raced.managedId);
396
+ }
397
+ }
398
+ }
399
+
400
+ const existing = await findUnrevokedManagedProjectApiKey(db, input);
401
+ if (existing && isManagedKeyUsable(existing)) {
402
+ return useExistingManagedProjectApiKey(db, existing);
403
+ }
404
+ throw new Error("Unable to ensure managed project API key");
405
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "declaration": true,
8
+ "noEmit": false,
9
+ "incremental": false
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["src/__tests__"]
13
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["src/**/*.test.ts"],
7
+ },
8
+ });