@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/.turbo/turbo-build.log +2 -0
- package/CHANGELOG.md +7 -0
- package/dist/crypto.d.ts +10 -0
- package/dist/crypto.js +41 -0
- package/dist/db.d.ts +6 -0
- package/dist/db.js +74 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +35 -0
- package/dist/schema.d.ts +817 -0
- package/dist/schema.js +80 -0
- package/dist/workspace.d.ts +197 -0
- package/dist/workspace.js +300 -0
- package/package.json +32 -0
- package/src/__tests__/crypto.test.ts +29 -0
- package/src/crypto.ts +50 -0
- package/src/db.ts +45 -0
- package/src/index.ts +36 -0
- package/src/schema.ts +82 -0
- package/src/workspace.ts +405 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
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
|
+
}));
|
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|