Skip to content

Commit c733c21

Browse files
committed
fix: i hate typescript
1 parent 8adfd65 commit c733c21

File tree

4 files changed

+303
-41
lines changed

4 files changed

+303
-41
lines changed

examples/databuddy-api.ts

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import { drizzle } from "drizzle-orm/node-postgres";
2-
import {
3-
boolean,
4-
index,
5-
jsonb,
6-
pgTable,
7-
text,
8-
timestamp,
9-
} from "drizzle-orm/pg-core";
2+
import { index, jsonb, pgTable, text } from "drizzle-orm/pg-core";
103
import { Pool } from "pg";
114
import type { ApiKeyRecord, PermissionScope } from "../src";
125
import { createKeys } from "../src";
@@ -17,30 +10,16 @@ export const apikey = pgTable(
1710
{
1811
id: text().primaryKey().notNull(),
1912
keyHash: text("key_hash").notNull(),
20-
ownerId: text("owner_id").notNull(),
21-
name: text("name"),
22-
description: text("description"),
23-
scopes: jsonb("scopes").default([]),
24-
resources: jsonb("resources").default({}),
25-
enabled: boolean("enabled").notNull().default(true),
26-
revokedAt: timestamp("revoked_at", { mode: "string" }),
27-
rotatedTo: text("rotated_to"),
28-
expiresAt: timestamp("expires_at", { mode: "string" }),
29-
createdAt: timestamp("created_at", { mode: "string" }).notNull(),
30-
lastUsedAt: timestamp("last_used_at", { mode: "string" }),
13+
metadata: jsonb("metadata").notNull(),
3114
},
32-
(table) => [
33-
index("apikey_key_hash_idx").on(table.keyHash),
34-
index("apikey_owner_id_idx").on(table.ownerId),
35-
index("apikey_enabled_idx").on(table.enabled),
36-
]
15+
(table) => [index("apikey_key_hash_idx").on(table.keyHash)]
3716
);
3817

3918
const pool = new Pool({
4019
connectionString: process.env.DATABASE_URL,
4120
});
4221

43-
const db = drizzle(pool);
22+
const db = drizzle(pool, { schema: { apikey } });
4423

4524
const keys = createKeys({
4625
prefix: "dt_bd_sk_",

src/manager.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,57 @@ import type { PermissionScope } from "./types/permissions-types";
2323
import type { Storage } from "./types/storage-types";
2424
import { logger } from "./utils/logger";
2525

26+
/**
27+
* Result of verifying an API key
28+
*/
2629
export type VerifyResult = {
30+
/** Whether the key is valid */
2731
valid: boolean;
32+
/** The API key record if valid */
2833
record?: ApiKeyRecord;
34+
/** Error message if invalid */
2935
error?: string;
3036
};
3137

38+
/**
39+
* Options for verifying API keys
40+
*/
3241
export type VerifyOptions = {
42+
/** Skip cache lookup (always query storage) */
3343
skipCache?: boolean;
44+
/** Override header names to look for */
3445
headerNames?: string[];
46+
/** Override extractBearer behavior */
3547
extractBearer?: boolean;
3648
/** Skip updating lastUsedAt timestamp (useful when autoTrackUsage is enabled) */
3749
skipTracking?: boolean;
3850
};
3951

52+
/**
53+
* API Key Manager for creating, verifying, and managing API keys
54+
*
55+
* @example
56+
* ```typescript
57+
* const keys = createKeys({
58+
* prefix: "sk_live_",
59+
* storage: "redis",
60+
* redis: redisClient
61+
* });
62+
*
63+
* // Create a key
64+
* const { key, record } = await keys.create({
65+
* ownerId: "user_123",
66+
* name: "Production Key",
67+
* scopes: ["read", "write"]
68+
* });
69+
*
70+
* // Verify a key
71+
* const result = await keys.verify(key);
72+
* if (result.valid) {
73+
* console.log("Key belongs to:", result.record?.metadata.ownerId);
74+
* }
75+
* ```
76+
*/
4077
export class ApiKeyManager {
4178
private readonly config: Config;
4279
private readonly storage: Storage;
@@ -124,6 +161,21 @@ export class ApiKeyManager {
124161
});
125162
}
126163

164+
/**
165+
* Extract API key from HTTP headers
166+
*
167+
* @param headers - HTTP headers object or Headers instance
168+
* @param options - Optional extraction options
169+
* @returns The extracted API key or null if not found
170+
*
171+
* @example
172+
* ```typescript
173+
* const key = keys.extractKey(req.headers);
174+
* if (key) {
175+
* console.log("Found key:", key);
176+
* }
177+
* ```
178+
*/
127179
extractKey(
128180
headers: Record<string, string | undefined> | Headers,
129181
options?: KeyExtractionOptions
@@ -136,6 +188,20 @@ export class ApiKeyManager {
136188
return extractKeyFromHeaders(headers, mergedOptions);
137189
}
138190

191+
/**
192+
* Check if an API key is present in HTTP headers
193+
*
194+
* @param headers - HTTP headers object or Headers instance
195+
* @param options - Optional extraction options
196+
* @returns True if an API key is found in headers
197+
*
198+
* @example
199+
* ```typescript
200+
* if (keys.hasKey(req.headers)) {
201+
* // API key is present
202+
* }
203+
* ```
204+
*/
139205
hasKey(
140206
headers: Record<string, string | undefined> | Headers,
141207
options?: KeyExtractionOptions
@@ -148,6 +214,28 @@ export class ApiKeyManager {
148214
return hasApiKey(headers, mergedOptions);
149215
}
150216

217+
/**
218+
* Verify an API key from a string or HTTP headers
219+
*
220+
* @param keyOrHeader - The API key string or HTTP headers object
221+
* @param options - Verification options
222+
* @returns Verification result with validity status and record
223+
*
224+
* @example
225+
* ```typescript
226+
* // Verify from string
227+
* const result = await keys.verify("sk_live_abc123...");
228+
*
229+
* // Verify from headers
230+
* const result = await keys.verify(req.headers);
231+
*
232+
* if (result.valid) {
233+
* console.log("Owner:", result.record?.metadata.ownerId);
234+
* } else {
235+
* console.log("Error:", result.error);
236+
* }
237+
* ```
238+
*/
151239
async verify(
152240
keyOrHeader: string | Record<string, string | undefined> | Headers,
153241
options: VerifyOptions = {}
@@ -264,6 +352,27 @@ export class ApiKeyManager {
264352
return { valid: true, record };
265353
}
266354

355+
/**
356+
* Create a new API key
357+
*
358+
* @param metadata - Metadata for the API key (ownerId is required)
359+
* @returns The generated key string and the stored record
360+
*
361+
* @example
362+
* ```typescript
363+
* const { key, record } = await keys.create({
364+
* ownerId: "user_123",
365+
* name: "Production Key",
366+
* description: "API key for production access",
367+
* scopes: ["read", "write"],
368+
* expiresAt: "2025-12-31T00:00:00.000Z",
369+
* tags: ["production", "api"]
370+
* });
371+
*
372+
* console.log("New key:", key);
373+
* console.log("Key ID:", record.id);
374+
* ```
375+
*/
267376
async create(
268377
metadata: Partial<ApiKeyMetadata>
269378
): Promise<{ key: string; record: ApiKeyRecord }> {
@@ -456,6 +565,27 @@ export class ApiKeyManager {
456565
return isExpired(record.metadata.expiresAt);
457566
}
458567

568+
/**
569+
* Check if an API key has a specific scope
570+
*
571+
* @param record - The API key record
572+
* @param scope - Required scope to check
573+
* @param options - Optional scope check options (e.g., resource filtering)
574+
* @returns True if the key has the required scope
575+
*
576+
* @example
577+
* ```typescript
578+
* const record = await storage.findById("key_id");
579+
* if (keys.hasScope(record, "read")) {
580+
* // Key has read permission
581+
* }
582+
*
583+
* // Check for resource-specific scope
584+
* if (keys.hasScope(record, "write", { resource: "project:123" })) {
585+
* // Key can write to project 123
586+
* }
587+
* ```
588+
*/
459589
hasScope(
460590
record: ApiKeyRecord,
461591
scope: PermissionScope,
@@ -469,6 +599,21 @@ export class ApiKeyManager {
469599
);
470600
}
471601

602+
/**
603+
* Check if an API key has any of the required scopes
604+
*
605+
* @param record - The API key record
606+
* @param requiredScopes - Array of scopes to check
607+
* @param options - Optional scope check options
608+
* @returns True if the key has at least one of the required scopes
609+
*
610+
* @example
611+
* ```typescript
612+
* if (keys.hasAnyScope(record, ["read", "write"])) {
613+
* // Key has read OR write permission
614+
* }
615+
* ```
616+
*/
472617
hasAnyScope(
473618
record: ApiKeyRecord,
474619
requiredScopes: PermissionScope[],
@@ -482,6 +627,21 @@ export class ApiKeyManager {
482627
);
483628
}
484629

630+
/**
631+
* Check if an API key has all required scopes
632+
*
633+
* @param record - The API key record
634+
* @param requiredScopes - Array of scopes to check
635+
* @param options - Optional scope check options
636+
* @returns True if the key has all required scopes
637+
*
638+
* @example
639+
* ```typescript
640+
* if (keys.hasAllScopes(record, ["read", "write"])) {
641+
* // Key has read AND write permissions
642+
* }
643+
* ```
644+
*/
485645
hasAllScopes(
486646
record: ApiKeyRecord,
487647
requiredScopes: PermissionScope[],
@@ -563,6 +723,32 @@ export class ApiKeyManager {
563723
}
564724
}
565725

726+
/**
727+
* Create an API key manager instance
728+
*
729+
* @param config - Configuration options for key generation and storage
730+
* @returns An ApiKeyManager instance for creating and verifying keys
731+
*
732+
* @example
733+
* ```typescript
734+
* // Simple in-memory setup
735+
* const keys = createKeys({ prefix: "sk_" });
736+
*
737+
* // Redis setup with caching
738+
* const keys = createKeys({
739+
* prefix: "sk_live_",
740+
* storage: "redis",
741+
* redis: redisClient,
742+
* cache: true,
743+
* cacheTtl: 300
744+
* });
745+
*
746+
* // Custom storage adapter
747+
* const keys = createKeys({
748+
* storage: myCustomStorage
749+
* });
750+
* ```
751+
*/
566752
export function createKeys(config: ConfigInput = {}): ApiKeyManager {
567753
return new ApiKeyManager(config);
568754
}

src/storage/drizzle.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
1-
import { and, arrayContains, eq, exists, type SQL, sql } from "drizzle-orm";
1+
import { and, arrayContains, eq, or } from "drizzle-orm";
22
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
33
import type { PgTable } from "drizzle-orm/pg-core";
4-
import type { apikey } from "../drizzle/schema";
54
import type { ApiKeyMetadata, ApiKeyRecord } from "../types/api-key-types";
65
import type { Storage } from "../types/storage-types";
76

87
/**
98
* PostgreSQL storage adapter for API keys using Drizzle ORM
109
*
11-
* Stores API keys with the following structure:
12-
* - id: Primary key identifier
13-
* - keyHash: SHA-256 hash of the API key
14-
* - metadata: JSONB field containing owner, scopes, and other metadata
10+
* **Required Table Columns:**
11+
* - `id`: TEXT PRIMARY KEY
12+
* - `keyHash`: TEXT
13+
* - `metadata`: JSONB
14+
*
15+
* You can add custom columns - they'll be ignored by this adapter.
16+
*
17+
* @example
18+
* ```typescript
19+
* import { apikey } from 'keypal/drizzle/schema';
20+
* const store = new DrizzleStore({ db, table: apikey });
21+
* ```
1522
*/
1623
export class DrizzleStore implements Storage {
1724
private readonly db: NodePgDatabase<Record<string, PgTable>>;
18-
private readonly table: typeof apikey;
25+
// biome-ignore lint/suspicious/noExplicitAny: Accept any Drizzle table type
26+
private readonly table: any;
1927

2028
constructor(options: {
2129
db: NodePgDatabase<Record<string, PgTable>>;
22-
table: typeof apikey;
30+
// biome-ignore lint/suspicious/noExplicitAny: Accept any Drizzle table type
31+
table: any;
2332
}) {
2433
this.db = options.db;
2534
this.table = options.table;
@@ -38,7 +47,11 @@ export class DrizzleStore implements Storage {
3847
};
3948
}
4049

41-
private toRow(record: ApiKeyRecord): typeof apikey.$inferSelect {
50+
private toRow(record: ApiKeyRecord): {
51+
id: string;
52+
keyHash: string;
53+
metadata: ApiKeyMetadata;
54+
} {
4255
return {
4356
id: record.id,
4457
keyHash: record.keyHash,
@@ -80,15 +93,15 @@ export class DrizzleStore implements Storage {
8093
}
8194

8295
async findByTags(tags: string[], ownerId?: string): Promise<ApiKeyRecord[]> {
83-
const conditions: SQL[] = [];
96+
// biome-ignore lint/suspicious/noExplicitAny: arrayContains returns SQL[], TypeScript incorrectly infers undefined
97+
const conditions: any = [];
8498

8599
if (tags.length > 0) {
86100
const lowercasedTags = tags.map((t) => t.toLowerCase());
87-
conditions.push(
88-
exists(
89-
sql`(select 1 from jsonb_array_elements_text(${this.table.metadata}->'tags') as tag where tag in ${lowercasedTags})`
90-
)
101+
const tagConditions = lowercasedTags.map((tag) =>
102+
arrayContains(this.table.metadata, { tags: [tag] })
91103
);
104+
conditions.push(or(...tagConditions));
92105
}
93106

94107
if (ownerId !== undefined) {

0 commit comments

Comments
 (0)