@donotlb/keypal 0.1.0

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/README.md ADDED
@@ -0,0 +1,1037 @@
1
+ # keypal
2
+
3
+ [![Test](https://github.com/izadoesdev/keypal/actions/workflows/test.yml/badge.svg)](https://github.com/izadoesdev/keypal/actions/workflows/test.yml)
4
+ [![Benchmark](https://github.com/izadoesdev/keypal/actions/workflows/benchmark.yml/badge.svg)](https://github.com/izadoesdev/keypal/actions/workflows/benchmark.yml)
5
+ [![npm version](https://badge.fury.io/js/keypal.svg)](https://badge.fury.io/js/keypal)
6
+
7
+ A TypeScript library for secure API key management with cryptographic hashing, expiration, scopes, and pluggable storage.
8
+
9
+ ## Features
10
+
11
+ - **Secure by Default**: SHA-256/SHA-512 hashing with optional salt and timing-safe comparison
12
+ - **Smart Key Detection**: Automatically extracts keys from `Authorization`, `x-api-key`, or custom headers
13
+ - **Built-in Caching**: Optional in-memory or Redis caching for validated keys
14
+ - **Flexible Storage**: Memory, Redis, Drizzle ORM, Prisma, and Kysely adapters included
15
+ - **Scope-based Permissions**: Fine-grained access control with resource-specific scopes
16
+ - **Tags**: Organize and find keys by tags
17
+ - **Key Management**: Enable/disable, rotate, and soft-revoke keys with audit trails
18
+ - **Audit Logging**: Track who did what, when, and why (opt-in)
19
+ - **TypeScript**: Full type safety
20
+ - **Zero Config**: Works out of the box with sensible defaults
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ npm install keypal
26
+ # or
27
+ bun add keypal
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import { createKeys } from 'keypal'
34
+
35
+ const keys = createKeys({
36
+ prefix: 'sk_',
37
+ cache: true,
38
+ })
39
+
40
+ // Create a key
41
+ const { key, record } = await keys.create({
42
+ ownerId: 'user_123',
43
+ scopes: ['read', 'write'],
44
+ })
45
+
46
+ // Verify from headers
47
+ const result = await keys.verify(request.headers)
48
+ if (result.valid) {
49
+ console.log('Authenticated:', result.record.metadata.ownerId)
50
+ }
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ ```typescript
56
+ import Redis from 'ioredis'
57
+
58
+ const redis = new Redis()
59
+
60
+ const keys = createKeys({
61
+ // Key generation
62
+ prefix: 'sk_prod_',
63
+ length: 32,
64
+ alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
65
+
66
+ // Security
67
+ algorithm: 'sha256', // or 'sha512'
68
+ salt: process.env.API_KEY_SALT,
69
+
70
+ // Storage (memory by default)
71
+ storage: 'redis', // or custom Storage instance
72
+ redis, // required when storage/cache is 'redis'
73
+
74
+ // Caching
75
+ cache: true, // in-memory cache
76
+ // cache: 'redis', // Redis cache
77
+ cacheTtl: 60,
78
+
79
+ // Revocation
80
+ revokedKeyTtl: 604800, // TTL for revoked keys in Redis (7 days), set to 0 to keep forever
81
+
82
+ // Usage tracking
83
+ autoTrackUsage: true, // Automatically update lastUsedAt on verify
84
+
85
+ // Audit logging (opt-in)
86
+ auditLogs: true, // Enable audit logging
87
+ auditContext: { // Default context for all audit logs (optional)
88
+ userId: 'system',
89
+ metadata: { service: 'api' }
90
+ },
91
+
92
+ // Header detection
93
+ headerNames: ['x-api-key', 'authorization'],
94
+ extractBearer: true,
95
+ })
96
+ ```
97
+
98
+ ## API
99
+
100
+ ### Creating & Managing Keys
101
+
102
+ ```typescript
103
+ // Create with plain object
104
+ const { key, record } = await keys.create({
105
+ ownerId: 'user_123',
106
+ name: 'Production Key',
107
+ description: 'Key for production API access',
108
+ scopes: ['read', 'write'],
109
+ tags: ['production', 'api'],
110
+ resources: {
111
+ 'project:123': ['read', 'write'],
112
+ 'project:456': ['read']
113
+ },
114
+ expiresAt: '2025-12-31',
115
+ enabled: true, // optional, defaults to true
116
+ })
117
+
118
+ // Create with ResourceBuilder (fluent API)
119
+ import { ResourceBuilder, createResourceBuilder } from 'keypal'
120
+
121
+ const resources = new ResourceBuilder()
122
+ .add('website', 'site123', ['read', 'write'])
123
+ .add('project', 'proj456', ['deploy'])
124
+ .addMany('website', ['site1', 'site2', 'site3'], ['read']) // Same scopes for multiple resources
125
+ .build()
126
+
127
+ const { key: key2, record: record2 } = await keys.create({
128
+ ownerId: 'user_123',
129
+ scopes: ['admin'],
130
+ resources, // Use the built resources object
131
+ })
132
+
133
+ // List
134
+ const userKeys = await keys.list('user_123')
135
+
136
+ // Find by tag
137
+ const taggedKeys = await keys.findByTag('production')
138
+ const multiTagKeys = await keys.findByTags(['production', 'api'])
139
+
140
+ // Find by ID or hash
141
+ const keyRecord = await keys.findById(record.id)
142
+ const keyByHash = await keys.findByHash(record.keyHash)
143
+
144
+ // Enable/Disable
145
+ await keys.enable(record.id)
146
+ await keys.disable(record.id)
147
+
148
+ // Rotate (create new key, mark old as revoked)
149
+ const { key: newKey, record: newRecord, oldRecord } = await keys.rotate(record.id, {
150
+ name: 'Updated Key',
151
+ scopes: ['read', 'write', 'admin'],
152
+ })
153
+
154
+ // Revoke (soft delete - keeps record with revokedAt timestamp)
155
+ await keys.revoke(record.id)
156
+ await keys.revokeAll('user_123')
157
+
158
+ // Update last used
159
+ await keys.updateLastUsed(record.id)
160
+ ```
161
+
162
+ ### Verifying Keys
163
+
164
+ ```typescript
165
+ // From headers (automatic detection)
166
+ const result = await keys.verify(request.headers)
167
+
168
+ // From string
169
+ const result = await keys.verify('sk_abc123')
170
+ const result = await keys.verify('Bearer sk_abc123')
171
+
172
+ // With options
173
+ const result = await keys.verify(headers, {
174
+ headerNames: ['x-custom-key'],
175
+ skipCache: true,
176
+ skipTracking: true, // Skip updating lastUsedAt (useful when autoTrackUsage is enabled)
177
+ })
178
+
179
+ // Check result
180
+ if (result.valid) {
181
+ console.log(result.record)
182
+ } else {
183
+ console.log(result.error) // Human-readable error message
184
+ console.log(result.errorCode) // Error code for programmatic handling (see ApiKeyErrorCode)
185
+ }
186
+ ```
187
+
188
+ ### Permission Checking
189
+
190
+ ```typescript
191
+ // Global scope checks
192
+ if (keys.hasScope(record, 'write')) { /* ... */ }
193
+ if (keys.hasAnyScope(record, ['admin', 'moderator'])) { /* ... */ }
194
+ if (keys.hasAllScopes(record, ['read', 'write'])) { /* ... */ }
195
+ if (keys.isExpired(record)) { /* ... */ }
196
+
197
+ // Resource-specific scope checks
198
+ // Check if key has 'read' scope for a specific resource
199
+ if (keys.checkResourceScope(record, 'website', 'site123', 'read')) { /* ... */ }
200
+
201
+ // Check if key has any of the specified scopes for a resource
202
+ if (keys.checkResourceAnyScope(record, 'website', 'site123', ['admin', 'write'])) { /* ... */ }
203
+
204
+ // Check if key has all specified scopes for a resource (checks both global and resource scopes)
205
+ if (keys.checkResourceAllScopes(record, 'website', 'site123', ['read', 'write'])) { /* ... */ }
206
+ ```
207
+
208
+ ### ResourceBuilder (Fluent API)
209
+
210
+ Build resource-specific scopes with a clean, chainable API:
211
+
212
+ ```typescript
213
+ import { ResourceBuilder, createResourceBuilder } from 'keypal'
214
+
215
+ // Basic usage
216
+ const resources = new ResourceBuilder()
217
+ .add('website', 'site123', ['read', 'write'])
218
+ .add('project', 'proj456', ['deploy'])
219
+ .build()
220
+
221
+ // Add scopes to multiple resources at once
222
+ const resources2 = new ResourceBuilder()
223
+ .addMany('website', ['site1', 'site2', 'site3'], ['read'])
224
+ .add('project', 'proj1', ['deploy', 'rollback'])
225
+ .build()
226
+
227
+ // Add single scopes
228
+ const resources3 = new ResourceBuilder()
229
+ .addOne('website', 'site123', 'read')
230
+ .addOne('website', 'site123', 'write')
231
+ .build()
232
+
233
+ // Modify existing resources
234
+ const builder = new ResourceBuilder()
235
+ .add('website', 'site123', ['read', 'write'])
236
+ .add('project', 'proj456', ['deploy'])
237
+
238
+ // Check if resource exists
239
+ if (builder.has('website', 'site123')) {
240
+ const scopes = builder.get('website', 'site123')
241
+ console.log(scopes) // ['read', 'write']
242
+ }
243
+
244
+ // Remove specific scopes
245
+ builder.removeScopes('website', 'site123', ['write'])
246
+
247
+ // Remove entire resource
248
+ builder.remove('project', 'proj456')
249
+
250
+ // Build final result
251
+ const finalResources = builder.build()
252
+
253
+ // Start from existing resources (useful for updates)
254
+ const existingResources = {
255
+ 'website:site123': ['read'],
256
+ 'project:proj456': ['deploy']
257
+ }
258
+
259
+ const updated = ResourceBuilder.from(existingResources)
260
+ .add('website', 'site123', ['write']) // Merges with existing
261
+ .add('team', 'team789', ['admin'])
262
+ .build()
263
+
264
+ // Use with createKeys
265
+ await keys.create({
266
+ ownerId: 'user_123',
267
+ scopes: ['admin'],
268
+ resources: updated
269
+ })
270
+ ```
271
+
272
+ **ResourceBuilder Methods:**
273
+ - `add(resourceType, resourceId, scopes)` - Add scopes to a resource (merges if exists)
274
+ - `addOne(resourceType, resourceId, scope)` - Add a single scope
275
+ - `addMany(resourceType, resourceIds, scopes)` - Add same scopes to multiple resources
276
+ - `remove(resourceType, resourceId)` - Remove entire resource
277
+ - `removeScopes(resourceType, resourceId, scopes)` - Remove specific scopes
278
+ - `has(resourceType, resourceId)` - Check if resource exists
279
+ - `get(resourceType, resourceId)` - Get scopes for a resource
280
+ - `clear()` - Clear all resources
281
+ - `build()` - Build and return the resources object
282
+ - `ResourceBuilder.from(resources)` - Create from existing resources object
283
+
284
+ ### Usage Tracking
285
+
286
+ ```typescript
287
+ // Enable automatic tracking in config
288
+ const keys = createKeys({
289
+ autoTrackUsage: true, // Automatically updates lastUsedAt on verify
290
+ })
291
+
292
+ // Manually update (always available)
293
+ await keys.updateLastUsed(record.id)
294
+
295
+ // Skip tracking for specific requests
296
+ const result = await keys.verify(headers, { skipTracking: true })
297
+ ```
298
+
299
+ ### Audit Logging
300
+
301
+ Track all key operations with context about who performed each action:
302
+
303
+ ```typescript
304
+ // Enable audit logging
305
+ const keys = createKeys({
306
+ auditLogs: true,
307
+ auditContext: {
308
+ // Default context merged into all logs
309
+ metadata: { environment: 'production' }
310
+ }
311
+ })
312
+
313
+ // Actions are automatically logged with optional context
314
+ await keys.create({
315
+ ownerId: 'user_123',
316
+ scopes: ['read']
317
+ }, {
318
+ userId: 'admin_456',
319
+ ip: '192.168.1.1',
320
+ metadata: { reason: 'New customer onboarding' }
321
+ })
322
+
323
+ await keys.revoke('key_123', {
324
+ userId: 'admin_789',
325
+ metadata: { reason: 'Security breach' }
326
+ })
327
+
328
+ // Query logs
329
+ const logs = await keys.getLogs({
330
+ keyId: 'key_123',
331
+ action: 'revoked',
332
+ startDate: '2025-01-01',
333
+ limit: 100
334
+ })
335
+
336
+ // Count logs
337
+ const count = await keys.countLogs({ action: 'created' })
338
+
339
+ // Get statistics
340
+ const stats = await keys.getLogStats('user_123')
341
+ console.log(stats.total)
342
+ console.log(stats.byAction.created)
343
+ console.log(stats.lastActivity)
344
+
345
+ // Clean up old logs
346
+ const deleted = await keys.deleteLogs({
347
+ endDate: '2024-01-01'
348
+ })
349
+
350
+ // Clear logs for a specific key
351
+ await keys.clearLogs('key_123')
352
+ ```
353
+
354
+ **Log Entry Structure:**
355
+
356
+ ```typescript
357
+ {
358
+ id: 'log_xyz',
359
+ action: 'created' | 'revoked' | 'rotated' | 'enabled' | 'disabled',
360
+ keyId: 'key_123',
361
+ ownerId: 'user_456',
362
+ timestamp: '2025-10-25T12:00:00.000Z',
363
+ data: {
364
+ userId: 'admin_789',
365
+ ip: '192.168.1.1',
366
+ metadata: { reason: 'Security breach' }
367
+ }
368
+ }
369
+ ```
370
+
371
+ ### Helper Methods
372
+
373
+ ```typescript
374
+ keys.hasKey(headers) // boolean - check if headers contain an API key
375
+ keys.extractKey(headers) // string | null - extract key from headers
376
+ keys.generateKey() // string - generate a new key (without saving)
377
+ keys.hashKey(key) // string - hash a key (useful for custom storage)
378
+ keys.invalidateCache(keyHash) // Promise<void> - manually invalidate cached key
379
+ ```
380
+
381
+ ### Standalone Utility Functions
382
+
383
+ You can also use these functions without a manager instance:
384
+
385
+ ```typescript
386
+ import {
387
+ isExpired,
388
+ getExpirationTime,
389
+ extractKeyFromHeaders,
390
+ hasApiKey,
391
+ hasScope,
392
+ hasAnyScope,
393
+ hasAllScopes,
394
+ ApiKeyErrorCode,
395
+ createApiKeyError
396
+ } from 'keypal'
397
+
398
+ // Check expiration
399
+ const expired = isExpired('2025-12-31T00:00:00.000Z')
400
+ const expirationDate = getExpirationTime('2025-12-31T00:00:00.000Z') // Date | null
401
+
402
+ // Extract key from headers
403
+ const key = extractKeyFromHeaders(request.headers, {
404
+ headerNames: ['x-api-key'],
405
+ extractBearer: true
406
+ })
407
+
408
+ // Check if headers have API key
409
+ if (hasApiKey(request.headers)) {
410
+ const key = extractKeyFromHeaders(request.headers)
411
+ }
412
+
413
+ // Check scopes (for plain scope arrays, not records)
414
+ const hasWrite = hasScope(['read', 'write'], 'write')
415
+ const hasAny = hasAnyScope(['read', 'write'], ['admin', 'write'])
416
+ const hasAll = hasAllScopes(['read', 'write'], ['read', 'write'])
417
+
418
+ // Error handling
419
+ if (!result.valid) {
420
+ switch (result.errorCode) {
421
+ case ApiKeyErrorCode.EXPIRED:
422
+ // Handle expired key
423
+ break
424
+ case ApiKeyErrorCode.REVOKED:
425
+ // Handle revoked key
426
+ break
427
+ case ApiKeyErrorCode.DISABLED:
428
+ // Handle disabled key
429
+ break
430
+ // ... other error codes
431
+ }
432
+ }
433
+
434
+ // Create custom errors
435
+ const error = createApiKeyError(ApiKeyErrorCode.INVALID_KEY, {
436
+ attemptedKey: 'sk_abc123'
437
+ })
438
+ ```
439
+
440
+ **Available Error Codes:**
441
+ - `MISSING_KEY` - No API key provided
442
+ - `INVALID_FORMAT` - API key format is invalid
443
+ - `INVALID_KEY` - API key does not exist
444
+ - `EXPIRED` - API key has expired
445
+ - `REVOKED` - API key has been revoked
446
+ - `DISABLED` - API key is disabled
447
+ - `STORAGE_ERROR` - Storage operation failed
448
+ - `CACHE_ERROR` - Cache operation failed
449
+ - `ALREADY_REVOKED` - Key is already revoked
450
+ - `ALREADY_ENABLED` - Key is already enabled
451
+ - `ALREADY_DISABLED` - Key is already disabled
452
+ - `CANNOT_MODIFY_REVOKED` - Cannot modify revoked key
453
+ - `KEY_NOT_FOUND` - API key not found
454
+ - `AUDIT_LOGGING_DISABLED` - Audit logging not enabled
455
+ - `STORAGE_NOT_SUPPORTED` - Storage doesn't support operation
456
+
457
+ ## Storage Examples
458
+
459
+ ### Memory (Default)
460
+
461
+ ```typescript
462
+ const keys = createKeys({ prefix: 'sk_' })
463
+ ```
464
+
465
+ ### Redis
466
+
467
+ ```typescript
468
+ import Redis from 'ioredis'
469
+
470
+ const redis = new Redis()
471
+
472
+ const keys = createKeys({
473
+ prefix: 'sk_',
474
+ storage: 'redis',
475
+ cache: 'redis',
476
+ redis,
477
+ })
478
+ ```
479
+
480
+ ### Drizzle ORM
481
+
482
+ ```typescript
483
+ import { drizzle } from 'drizzle-orm/node-postgres'
484
+ import { Pool } from 'pg'
485
+ import { DrizzleStore } from 'keypal/drizzle'
486
+ import { apikey } from 'keypal/drizzle/schema'
487
+ import { createKeys } from 'keypal'
488
+
489
+ const pool = new Pool({
490
+ connectionString: process.env.DATABASE_URL
491
+ })
492
+
493
+ const db = drizzle(pool, { schema: { apikey } })
494
+
495
+ const keys = createKeys({
496
+ prefix: 'sk_prod_',
497
+ storage: new DrizzleStore({ db, table: apikey }),
498
+ cache: true,
499
+ })
500
+ ```
501
+
502
+ **Setup Database Schema:**
503
+
504
+ ```typescript
505
+ // src/drizzle/schema.ts
506
+ import { index, jsonb, pgTable, text, unique } from 'drizzle-orm/pg-core'
507
+
508
+ export const apikey = pgTable(
509
+ 'apikey',
510
+ {
511
+ id: text().primaryKey().notNull(),
512
+ keyHash: text('key_hash').notNull(),
513
+ metadata: jsonb('metadata').notNull(),
514
+ },
515
+ (table) => [
516
+ index('apikey_key_hash_idx').on(table.keyHash),
517
+ unique('apikey_key_hash_unique').on(table.keyHash),
518
+ ]
519
+ )
520
+ ```
521
+
522
+ **Generate migrations:**
523
+
524
+ ```bash
525
+ bun run db:generate
526
+ bun run db:push
527
+ ```
528
+
529
+ **Use Drizzle Studio:**
530
+
531
+ ```bash
532
+ bun run studio
533
+ ```
534
+
535
+ ### Prisma
536
+
537
+ ```typescript
538
+ import { PrismaClient } from '@prisma/client'
539
+ import { PrismaStore } from 'keypal/prisma'
540
+ import { createKeys } from 'keypal'
541
+
542
+ const prisma = new PrismaClient()
543
+
544
+ const keys = createKeys({
545
+ prefix: 'sk_prod_',
546
+ storage: new PrismaStore({ prisma, model: 'apiKey' }),
547
+ cache: true,
548
+ })
549
+ ```
550
+
551
+ **Setup Prisma Schema:**
552
+
553
+ ```prisma
554
+ model ApiKey {
555
+ id String @id @default(cuid())
556
+ keyHash String @unique
557
+ metadata Json
558
+
559
+ @@index([keyHash])
560
+ @@map("api_keys")
561
+ }
562
+ ```
563
+
564
+ ### Kysely
565
+
566
+ ```typescript
567
+ import { Kysely, PostgresDialect } from 'kysely'
568
+ import { Pool } from 'pg'
569
+ import { KyselyStore } from 'keypal/kysely'
570
+ import { createKeys } from 'keypal'
571
+
572
+ const db = new Kysely({
573
+ dialect: new PostgresDialect({
574
+ pool: new Pool({
575
+ connectionString: process.env.DATABASE_URL
576
+ })
577
+ })
578
+ })
579
+
580
+ const keys = createKeys({
581
+ prefix: 'sk_prod_',
582
+ storage: new KyselyStore({ db, tableName: 'api_keys' }),
583
+ cache: true,
584
+ })
585
+ ```
586
+
587
+ **Setup Database Schema (PostgreSQL):**
588
+
589
+ ```sql
590
+ CREATE TABLE api_keys (
591
+ id TEXT PRIMARY KEY,
592
+ "keyHash" TEXT UNIQUE NOT NULL,
593
+ metadata JSONB NOT NULL
594
+ );
595
+
596
+ CREATE INDEX api_keys_key_hash_idx ON api_keys("keyHash");
597
+ ```
598
+
599
+ > **Note**: The column uses camelCase (`keyHash`) by default. For MySQL, omit the quotes around `keyHash`.
600
+
601
+ ### Custom Storage
602
+
603
+ ```typescript
604
+ import { type Storage } from 'keypal'
605
+
606
+ const customStorage: Storage = {
607
+ save: async (record) => { /* ... */ },
608
+ findByHash: async (keyHash) => { /* ... */ },
609
+ findById: async (id) => { /* ... */ },
610
+ findByOwner: async (ownerId) => { /* ... */ },
611
+ findByTag: async (tag, ownerId) => { /* ... */ },
612
+ findByTags: async (tags, ownerId) => { /* ... */ },
613
+ updateMetadata: async (id, metadata) => { /* ... */ },
614
+ delete: async (id) => { /* ... */ },
615
+ deleteByOwner: async (ownerId) => { /* ... */ },
616
+ }
617
+
618
+ const keys = createKeys({
619
+ storage: customStorage,
620
+ })
621
+ ```
622
+
623
+ ## Error Handling Best Practices
624
+
625
+ ### Comprehensive Error Handling
626
+
627
+ ```typescript
628
+ import { createKeys, ApiKeyErrorCode } from 'keypal'
629
+
630
+ const keys = createKeys({
631
+ prefix: 'sk_',
632
+ storage: 'redis',
633
+ redis,
634
+ })
635
+
636
+ // Verify with comprehensive error handling
637
+ const result = await keys.verify(request.headers)
638
+
639
+ if (!result.valid) {
640
+ switch (result.errorCode) {
641
+ case ApiKeyErrorCode.MISSING_KEY:
642
+ return { error: 'API key is required', statusCode: 401 }
643
+
644
+ case ApiKeyErrorCode.INVALID_FORMAT:
645
+ return { error: 'Invalid API key format', statusCode: 401 }
646
+
647
+ case ApiKeyErrorCode.INVALID_KEY:
648
+ return { error: 'Invalid API key', statusCode: 401 }
649
+
650
+ case ApiKeyErrorCode.EXPIRED:
651
+ return { error: 'API key has expired', statusCode: 401 }
652
+
653
+ case ApiKeyErrorCode.REVOKED:
654
+ return { error: 'API key has been revoked', statusCode: 401 }
655
+
656
+ case ApiKeyErrorCode.DISABLED:
657
+ return { error: 'API key is disabled', statusCode: 403 }
658
+
659
+ default:
660
+ return { error: 'Authentication failed', statusCode: 401 }
661
+ }
662
+ }
663
+
664
+ // Key is valid, proceed with request
665
+ console.log('Authenticated user:', result.record.metadata.ownerId)
666
+ ```
667
+
668
+ ### Handling Storage Errors
669
+
670
+ ```typescript
671
+ import { createKeys, createApiKeyError, ApiKeyErrorCode } from 'keypal'
672
+
673
+ try {
674
+ // Create a key
675
+ const { key, record } = await keys.create({
676
+ ownerId: 'user_123',
677
+ scopes: ['read', 'write'],
678
+ })
679
+
680
+ return { success: true, key, keyId: record.id }
681
+ } catch (error) {
682
+ console.error('Failed to create API key:', error)
683
+
684
+ // Handle specific error types
685
+ if (error instanceof Error) {
686
+ if (error.message.includes('duplicate')) {
687
+ return { success: false, error: 'Duplicate key detected' }
688
+ }
689
+ if (error.message.includes('connection')) {
690
+ return { success: false, error: 'Database connection failed' }
691
+ }
692
+ }
693
+
694
+ return { success: false, error: 'Failed to create API key' }
695
+ }
696
+ ```
697
+
698
+ ### Handling Key Operations
699
+
700
+ ```typescript
701
+ // Revoke with error handling
702
+ try {
703
+ await keys.revoke(keyId, {
704
+ userId: 'admin_123',
705
+ metadata: { reason: 'User request' }
706
+ })
707
+ } catch (error) {
708
+ if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
709
+ return { error: 'Key not found', statusCode: 404 }
710
+ }
711
+ if (error.code === ApiKeyErrorCode.ALREADY_REVOKED) {
712
+ return { error: 'Key is already revoked', statusCode: 400 }
713
+ }
714
+ throw error // Re-throw unexpected errors
715
+ }
716
+
717
+ // Enable/Disable with error handling
718
+ try {
719
+ await keys.enable(keyId)
720
+ } catch (error) {
721
+ if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
722
+ return { error: 'Key not found', statusCode: 404 }
723
+ }
724
+ if (error.code === ApiKeyErrorCode.ALREADY_ENABLED) {
725
+ return { message: 'Key was already enabled', statusCode: 200 }
726
+ }
727
+ if (error.code === ApiKeyErrorCode.CANNOT_MODIFY_REVOKED) {
728
+ return { error: 'Cannot enable a revoked key', statusCode: 400 }
729
+ }
730
+ throw error
731
+ }
732
+
733
+ // Rotate with error handling
734
+ try {
735
+ const { key: newKey, record, oldRecord } = await keys.rotate(keyId, {
736
+ scopes: ['read', 'write', 'admin'],
737
+ })
738
+ return { success: true, key: newKey, keyId: record.id }
739
+ } catch (error) {
740
+ if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
741
+ return { error: 'Key not found', statusCode: 404 }
742
+ }
743
+ if (error.code === ApiKeyErrorCode.CANNOT_MODIFY_REVOKED) {
744
+ return { error: 'Cannot rotate a revoked key', statusCode: 400 }
745
+ }
746
+ throw error
747
+ }
748
+ ```
749
+
750
+ ### Drizzle Storage Error Handling
751
+
752
+ ```typescript
753
+ import { DrizzleStore } from 'keypal/drizzle'
754
+ import { drizzle } from 'drizzle-orm/node-postgres'
755
+ import { Pool } from 'pg'
756
+ import { apikey } from 'keypal/drizzle/schema'
757
+
758
+ // Initialize with connection error handling
759
+ let pool: Pool
760
+ let store: DrizzleStore
761
+
762
+ try {
763
+ pool = new Pool({
764
+ connectionString: process.env.DATABASE_URL,
765
+ // Connection pool settings
766
+ max: 20,
767
+ idleTimeoutMillis: 30000,
768
+ connectionTimeoutMillis: 2000,
769
+ })
770
+
771
+ // Test connection
772
+ await pool.query('SELECT 1')
773
+
774
+ const db = drizzle(pool, { schema: { apikey } })
775
+ store = new DrizzleStore({ db, table: apikey })
776
+
777
+ console.log('Database connection established')
778
+ } catch (error) {
779
+ console.error('Failed to connect to database:', error)
780
+ throw new Error('Database initialization failed')
781
+ }
782
+
783
+ const keys = createKeys({
784
+ prefix: 'sk_',
785
+ storage: store,
786
+ cache: true,
787
+ })
788
+
789
+ // Update metadata with error handling
790
+ try {
791
+ await store.updateMetadata(keyId, {
792
+ name: 'Updated Key',
793
+ scopes: ['admin'],
794
+ })
795
+ } catch (error) {
796
+ if (error.message.includes('not found')) {
797
+ return { error: 'Key not found', statusCode: 404 }
798
+ }
799
+ console.error('Failed to update key metadata:', error)
800
+ throw error
801
+ }
802
+
803
+ // Handle duplicate key errors
804
+ try {
805
+ await keys.create({
806
+ ownerId: 'user_123',
807
+ name: 'My Key',
808
+ })
809
+ } catch (error) {
810
+ // PostgreSQL duplicate key error
811
+ if (error.code === '23505') {
812
+ return { error: 'Duplicate key detected', statusCode: 409 }
813
+ }
814
+ throw error
815
+ }
816
+
817
+ // Graceful shutdown
818
+ process.on('SIGTERM', async () => {
819
+ await pool.end()
820
+ console.log('Database connection closed')
821
+ })
822
+ ```
823
+
824
+ ## Framework Example (Hono)
825
+
826
+ ```typescript
827
+ import { Hono } from 'hono'
828
+ import { createKeys, ApiKeyErrorCode } from 'keypal'
829
+ import Redis from 'ioredis'
830
+
831
+ const redis = new Redis()
832
+
833
+ const keys = createKeys({
834
+ prefix: 'sk_',
835
+ storage: 'redis',
836
+ cache: 'redis',
837
+ redis,
838
+ auditLogs: true,
839
+ })
840
+
841
+ const app = new Hono()
842
+
843
+ // Authentication middleware with comprehensive error handling
844
+ app.use('/api/*', async (c, next) => {
845
+ const result = await keys.verify(c.req.raw.headers)
846
+
847
+ if (!result.valid) {
848
+ // Log failed authentication attempts
849
+ console.warn('Authentication failed:', {
850
+ error: result.error,
851
+ errorCode: result.errorCode,
852
+ path: c.req.path,
853
+ ip: c.req.header('x-forwarded-for'),
854
+ })
855
+
856
+ // Return appropriate error response
857
+ const statusCode = result.errorCode === ApiKeyErrorCode.DISABLED ? 403 : 401
858
+ return c.json({ error: result.error, code: result.errorCode }, statusCode)
859
+ }
860
+
861
+ // Store record in context for downstream handlers
862
+ c.set('apiKey', result.record)
863
+
864
+ // Track usage (fire and forget)
865
+ if (result.record) {
866
+ keys.updateLastUsed(result.record.id).catch((err) => {
867
+ console.error('Failed to update lastUsedAt:', err)
868
+ })
869
+ }
870
+
871
+ await next()
872
+ })
873
+
874
+ // Protected route with scope check
875
+ app.get('/api/data', async (c) => {
876
+ const record = c.get('apiKey')
877
+
878
+ if (!keys.hasScope(record, 'read')) {
879
+ return c.json({ error: 'Insufficient permissions' }, 403)
880
+ }
881
+
882
+ return c.json({ data: 'sensitive data' })
883
+ })
884
+
885
+ // Resource-specific scope check
886
+ app.get('/api/projects/:id', async (c) => {
887
+ const record = c.get('apiKey')
888
+ const projectId = c.req.param('id')
889
+
890
+ // Check if key has read scope for this specific project
891
+ if (!keys.checkResourceScope(record, 'project', projectId, 'read')) {
892
+ return c.json({ error: 'No access to this project' }, 403)
893
+ }
894
+
895
+ return c.json({ project: { id: projectId } })
896
+ })
897
+
898
+ // Create API key endpoint
899
+ app.post('/api/keys', async (c) => {
900
+ const record = c.get('apiKey')
901
+
902
+ // Only admins can create keys
903
+ if (!keys.hasScope(record, 'admin')) {
904
+ return c.json({ error: 'Admin permission required' }, 403)
905
+ }
906
+
907
+ try {
908
+ const body = await c.req.json()
909
+
910
+ const { key, record: newRecord } = await keys.create({
911
+ ownerId: body.ownerId,
912
+ name: body.name,
913
+ scopes: body.scopes,
914
+ expiresAt: body.expiresAt,
915
+ }, {
916
+ userId: record.metadata.ownerId,
917
+ ip: c.req.header('x-forwarded-for'),
918
+ metadata: { action: 'api_create' },
919
+ })
920
+
921
+ return c.json({
922
+ success: true,
923
+ key, // Only returned once!
924
+ keyId: newRecord.id
925
+ })
926
+ } catch (error) {
927
+ console.error('Failed to create key:', error)
928
+ return c.json({ error: 'Failed to create key' }, 500)
929
+ }
930
+ })
931
+
932
+ // Revoke API key endpoint
933
+ app.delete('/api/keys/:id', async (c) => {
934
+ const record = c.get('apiKey')
935
+ const keyId = c.req.param('id')
936
+
937
+ try {
938
+ // Verify ownership or admin permission
939
+ const keyToRevoke = await keys.findById(keyId)
940
+
941
+ if (!keyToRevoke) {
942
+ return c.json({ error: 'Key not found' }, 404)
943
+ }
944
+
945
+ const isOwner = keyToRevoke.metadata.ownerId === record.metadata.ownerId
946
+ const isAdmin = keys.hasScope(record, 'admin')
947
+
948
+ if (!isOwner && !isAdmin) {
949
+ return c.json({ error: 'Not authorized' }, 403)
950
+ }
951
+
952
+ await keys.revoke(keyId, {
953
+ userId: record.metadata.ownerId,
954
+ ip: c.req.header('x-forwarded-for'),
955
+ metadata: { via: 'api' },
956
+ })
957
+
958
+ return c.json({ success: true })
959
+ } catch (error) {
960
+ if (error.code === ApiKeyErrorCode.KEY_NOT_FOUND) {
961
+ return c.json({ error: 'Key not found' }, 404)
962
+ }
963
+ if (error.code === ApiKeyErrorCode.ALREADY_REVOKED) {
964
+ return c.json({ error: 'Key is already revoked' }, 400)
965
+ }
966
+
967
+ console.error('Failed to revoke key:', error)
968
+ return c.json({ error: 'Failed to revoke key' }, 500)
969
+ }
970
+ })
971
+ ```
972
+
973
+ ## Security Best Practices
974
+
975
+ 1. **Use a salt in production**:
976
+ ```typescript
977
+ const keys = createKeys({
978
+ salt: process.env.API_KEY_SALT,
979
+ algorithm: 'sha512',
980
+ })
981
+ ```
982
+
983
+ 2. **Set expiration dates**: Don't create keys that never expire
984
+
985
+ 3. **Use scopes**: Implement least-privilege access
986
+
987
+ 4. **Enable caching**: Reduce database load in production
988
+
989
+ 5. **Use HTTPS**: Always use HTTPS to prevent key interception
990
+
991
+ 6. **Monitor usage**: Track `lastUsedAt` to identify unused keys
992
+
993
+ 7. **Rotate keys**: Implement regular key rotation policies
994
+ ```typescript
995
+ // Rotate keys periodically
996
+ const { key: newKey } = await keys.rotate(oldRecord.id)
997
+ ```
998
+
999
+ 8. **Use soft revocation**: Revoked keys are kept with `revokedAt` timestamp for audit trails (Redis TTL: 7 days, Drizzle: forever)
1000
+
1001
+ 9. **Enable/Disable rather than revoke**: Temporarily disable keys instead of revoking them
1002
+
1003
+ ## TypeScript Types
1004
+
1005
+ ```typescript
1006
+ interface ApiKeyRecord {
1007
+ id: string
1008
+ keyHash: string
1009
+ metadata: ApiKeyMetadata
1010
+ }
1011
+
1012
+ interface ApiKeyMetadata {
1013
+ ownerId: string
1014
+ name?: string
1015
+ description?: string
1016
+ scopes?: string[]
1017
+ resources?: Record<string, string[]> // Resource-specific scopes (e.g., { "project:123": ["read", "write"] })
1018
+ tags?: string[]
1019
+ expiresAt: string | null
1020
+ createdAt?: string
1021
+ lastUsedAt?: string
1022
+ enabled?: boolean
1023
+ revokedAt?: string | null
1024
+ rotatedTo?: string | null
1025
+ }
1026
+
1027
+ interface VerifyResult {
1028
+ valid: boolean
1029
+ record?: ApiKeyRecord
1030
+ error?: string
1031
+ errorCode?: ApiKeyErrorCode
1032
+ }
1033
+ ```
1034
+
1035
+ ## License
1036
+
1037
+ MIT