Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
92d2382
fix: fix typescript error of array at index 0 possibly undefined
max-programming Oct 25, 2025
054f15b
fix: minor typescript error fix (array index possibly undefined)
max-programming Oct 25, 2025
b0e39b9
lint: set biome excessive complexity to 50 and warning to fix errors …
max-programming Oct 25, 2025
b31d4b2
feat: add findByTag method to ApiKeyManager and storage implementatio…
max-programming Oct 25, 2025
3e1a436
docs: remove key tags from feature suggestions
max-programming Oct 25, 2025
756b9bc
docs: added findByTag method in readme
max-programming Oct 25, 2025
cad6983
chore: remove redundant todo comment
max-programming Oct 25, 2025
f9125d9
feat: define separate methods for findByTag and findByTags
max-programming Oct 25, 2025
689cac9
fix: missing test coverage, drizzle sql construction, owner filtering…
izadoesdev Oct 25, 2025
c74e819
fix: filter by owner id test
izadoesdev Oct 25, 2025
d39fc01
fix: prevent sql injection
max-programming Oct 25, 2025
93b7298
fix: redis failing tests and commands
max-programming Oct 25, 2025
a053b8a
docs: add missing method
max-programming Oct 25, 2025
60ad9ae
feat: have two separate methods for finding by single tag or multiple…
max-programming Oct 25, 2025
daec392
fix: return empty array if conditions is empty
max-programming Oct 25, 2025
0b4c8fa
fix: handle tag sets on update
max-programming Oct 25, 2025
266b493
fix: manual sql query for tag filtering
max-programming Oct 25, 2025
6c64d79
Merge branch 'main' into feat/tags
izadoesdev Oct 25, 2025
bc52bd5
fix: benchmark action
max-programming Oct 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ jobs:
name: Benchmark
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write

steps:
- name: Checkout code
Expand Down
24 changes: 7 additions & 17 deletions FEATURE_SUGGESTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- ✅ Custom alphabet for key generation
- ✅ Salt for hashing
- ✅ Update last used timestamp
- ✅ Key tags/labels

## Recommended Additions

Expand Down Expand Up @@ -44,18 +45,7 @@ await keys.trackUsage(keyId, {
const stats = await keys.getUsageStats(keyId)
```

### 4. Key Tags/Labels
```typescript
await keys.create({
ownerId: 'user_123',
tags: ['production', 'billing-api'],
})

// Find by tag
const keys = await keys.findByTag('production')
```

### 5. Webhook Events
### 4. Webhook Events
```typescript
keys.on('key.created', async (event) => {
await sendWebhook(event.ownerId, 'key_created', event.data)
Expand All @@ -70,7 +60,7 @@ keys.on('key.expired', async (event) => {
})
```

### 6. IP Whitelisting
### 5. IP Whitelisting
```typescript
await keys.create({
ownerId: 'user_123',
Expand All @@ -80,7 +70,7 @@ await keys.create({
await keys.verify(key, { ipAddress: req.ip })
```

### 7. Request Signing
### 6. Request Signing
```typescript
// HMAC-based request signing
const signature = keys.sign(request, apiKey)
Expand All @@ -89,7 +79,7 @@ const signature = keys.sign(request, apiKey)
const isValid = await keys.verifySignature(request, signature, keyId)
```

### 8. Bulk Operations
### 7. Bulk Operations
```typescript
// Bulk create
const results = await keys.createBulk([
Expand All @@ -101,7 +91,7 @@ const results = await keys.createBulk([
await keys.revokeBulk(['key_1', 'key_2', 'key_3'])
```

### 9. Key Templates
### 8. Key Templates
```typescript
// Define reusable templates
keys.defineTemplate('readonly', {
Expand All @@ -114,7 +104,7 @@ const { key } = await keys.createFromTemplate('readonly', {
})
```

### 10. Audit Logging
### 9. Audit Logging
```typescript
interface AuditLog {
action: 'created' | 'verified' | 'revoked' | 'updated'
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ const customStorage: Storage = {
findByHash: async (keyHash) => { /* ... */ },
findById: async (id) => { /* ... */ },
findByOwner: async (ownerId) => { /* ... */ },
findByTag: async (tag, ownerId) => { /* ... */ },
findByTags: async (tags, ownerId) => { /* ... */ },
updateMetadata: async (id, metadata) => { /* ... */ },
delete: async (id) => { /* ... */ },
deleteByOwner: async (ownerId) => { /* ... */ },
Expand Down
12 changes: 12 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,17 @@
"enabled": true,
"indentWidth": 2,
"indentStyle": "tab"
},
"linter": {
"rules": {
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "warn",
"options": {
"maxAllowedComplexity": 50
}
}
}
}
}
}
13 changes: 13 additions & 0 deletions src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export class ApiKeyManager {
const key = this.generateKey();
const keyHash = this.hashKey(key);
const now = new Date().toISOString();
const tags = metadata.tags?.map((t) => t.toLowerCase());

const record: ApiKeyRecord = {
id: nanoid(),
Expand All @@ -286,6 +287,7 @@ export class ApiKeyManager {
enabled: metadata.enabled ?? true,
revokedAt: null,
rotatedTo: null,
tags,
},
};

Expand All @@ -301,6 +303,14 @@ export class ApiKeyManager {
return await this.storage.findById(id);
}

async findByTags(tags: string[], ownerId?: string): Promise<ApiKeyRecord[]> {
return await this.storage.findByTags(tags, ownerId);
}

async findByTag(tag: string, ownerId?: string): Promise<ApiKeyRecord[]> {
return await this.storage.findByTag(tag, ownerId);
}

async list(ownerId: string): Promise<ApiKeyRecord[]> {
return await this.storage.findByOwner(ownerId);
}
Expand Down Expand Up @@ -394,6 +404,9 @@ export class ApiKeyManager {
scopes: metadata?.scopes ?? oldRecord.metadata.scopes,
resources: metadata?.resources ?? oldRecord.metadata.resources,
expiresAt: metadata?.expiresAt ?? oldRecord.metadata.expiresAt,
tags: metadata?.tags
? metadata.tags.map((t) => t.toLowerCase())
: oldRecord.metadata.tags,
});

await this.storage.updateMetadata(id, {
Expand Down
81 changes: 76 additions & 5 deletions src/storage/drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,69 @@ describe("DrizzleStore", () => {
});
});

describe("findByTag", () => {
it("should find all records by one tag", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

const found = await store.findByTag("test");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});

it("should find all records by multiple tags (OR logic)", async () => {
const { record: r1 } = await keys.create({
ownerId: "user_123",
tags: ["test", "key"], // Has both tags
});

const { record: r2 } = await keys.create({
ownerId: "user_123",
tags: ["test"], // Only has 'test', not 'key'
});

const found = await store.findByTags(["test", "key"]);
expect(found).toHaveLength(2); // Should return BOTH records (OR logic)
expect(found.some((r) => r.id === r1.id)).toBe(true);
expect(found.some((r) => r.id === r2.id)).toBe(true);
});

it("should find all records by owner and tag", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test"],
});

// Create a key with same tag but different owner
await keys.create({
ownerId: "user_456",
tags: ["test"],
});

const found = await store.findByTag("test", "user_123");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});

it("should find all records by owner and multiple tags", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

await keys.create({
ownerId: "user_456",
tags: ["test", "key"],
});

const found = await store.findByTags(["test", "key"], "user_123");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});
});

describe("updateMetadata", () => {
it("should update metadata", async () => {
const { record } = await keys.create({
Expand Down Expand Up @@ -607,7 +670,11 @@ describe("DrizzleStore", () => {
await Promise.all(promises);

for (let i = 0; i < records.length; i++) {
const found = await store.findById(records[i].record.id);
const record = records.at(i);
if (!record) {
continue;
}
const found = await store.findById(record.record.id);
expect(found?.metadata.name).toBe(`Updated ${i}`);
}
});
Expand Down Expand Up @@ -1025,7 +1092,6 @@ describe("DrizzleStore", () => {
});

it("should handle rapid sequential saves", async () => {
// biome-ignore lint/style/noMagicNumbers: reduced count for testing
const TEST_COUNT = 100;
for (let i = 0; i < TEST_COUNT; i++) {
await keys.create({
Expand Down Expand Up @@ -1053,10 +1119,15 @@ describe("DrizzleStore", () => {
// Update some of them concurrently
const updatePromises = Array.from(
{ length: MIXED_UPDATES_COUNT },
(_, i) =>
store.updateMetadata(records[i]?.id || "", {
(_, i) => {
const record = records[i];
if (!record) {
throw new Error("Record not found");
}
return store.updateMetadata(record.id, {
name: `Updated ${i}`,
})
});
}
);

await Promise.all(updatePromises);
Expand Down
38 changes: 35 additions & 3 deletions src/storage/drizzle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { arrayContains, eq } from "drizzle-orm";
import { and, arrayContains, eq, exists, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { PgTable } from "drizzle-orm/pg-core";
import type { apikey } from "../drizzle/schema";
Expand Down Expand Up @@ -57,7 +57,7 @@ export class DrizzleStore implements Storage {
.where(eq(this.table.keyHash, keyHash))
.limit(1);

return rows.length > 0 ? this.toRecord(rows[0]) : null;
return rows.length > 0 && rows[0] ? this.toRecord(rows[0]) : null;
}

async findById(id: string): Promise<ApiKeyRecord | null> {
Expand All @@ -67,7 +67,7 @@ export class DrizzleStore implements Storage {
.where(eq(this.table.id, id))
.limit(1);

return rows.length > 0 ? this.toRecord(rows[0]) : null;
return rows.length > 0 && rows[0] ? this.toRecord(rows[0]) : null;
}

async findByOwner(ownerId: string): Promise<ApiKeyRecord[]> {
Expand All @@ -79,6 +79,38 @@ export class DrizzleStore implements Storage {
return rows.map(this.toRecord);
}

async findByTags(tags: string[], ownerId?: string): Promise<ApiKeyRecord[]> {
const conditions: SQL[] = [];

if (tags.length > 0) {
const lowercasedTags = tags.map((t) => t.toLowerCase());
conditions.push(
exists(
sql`(select 1 from jsonb_array_elements_text(${this.table.metadata}->'tags') as tag where tag in ${lowercasedTags})`
)
);
}

if (ownerId !== undefined) {
conditions.push(arrayContains(this.table.metadata, { ownerId }));
}

if (conditions.length === 0) {
return [];
}

const rows = await this.db
.select()
.from(this.table)
.where(and(...conditions));

return rows.map(this.toRecord);
}

async findByTag(tag: string, ownerId?: string): Promise<ApiKeyRecord[]> {
return await this.findByTags([tag], ownerId);
}

async updateMetadata(
id: string,
metadata: Partial<ApiKeyMetadata>
Expand Down
63 changes: 63 additions & 0 deletions src/storage/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,69 @@ describe("MemoryStore", () => {
});
});

describe("findByTag", () => {
it("should find all records by one tag", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

const found = await store.findByTag("test");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});

it("should find all records by multiple tags (OR logic)", async () => {
const { record: r1 } = await keys.create({
ownerId: "user_123",
tags: ["test", "key"], // Has both tags
});

const { record: r2 } = await keys.create({
ownerId: "user_123",
tags: ["test"], // Only has 'test', not 'key'
});

const found = await store.findByTags(["test", "key"]);
expect(found).toHaveLength(2); // Should return BOTH records (OR logic)
expect(found.some((r) => r.id === r1.id)).toBe(true);
expect(found.some((r) => r.id === r2.id)).toBe(true);
});

it("should find all records by owner and tag", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test"],
});

// Create a key with same tag but different owner
await keys.create({
ownerId: "user_456",
tags: ["test"],
});

const found = await store.findByTag("test", "user_123");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});

it("should find all records by owner and multiple tags", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

await keys.create({
ownerId: "user_456",
tags: ["test", "key"],
});

const found = await store.findByTags(["test", "key"], "user_123");
expect(found).toHaveLength(1);
expect(found[0]?.id).toBe(record.id);
});
});

describe("updateMetadata", () => {
it("should update metadata for a record", async () => {
const { record } = await keys.create({
Expand Down
Loading