Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ const customStorage: Storage = {
findByHash: async (keyHash) => { /* ... */ },
findById: async (id) => { /* ... */ },
findByOwner: async (ownerId) => { /* ... */ },
findByTag: async (tag, 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
}
}
}
}
}
}
10 changes: 10 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,13 @@ export class ApiKeyManager {
return await this.storage.findById(id);
}

async findByTag(
tag: string | 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 +403,7 @@ export class ApiKeyManager {
scopes: metadata?.scopes ?? oldRecord.metadata.scopes,
resources: metadata?.resources ?? oldRecord.metadata.resources,
expiresAt: metadata?.expiresAt ?? oldRecord.metadata.expiresAt,
tags: metadata?.tags ?? oldRecord.metadata.tags,
});

await this.storage.updateMetadata(id, {
Expand Down
41 changes: 40 additions & 1 deletion src/storage/drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,41 @@ 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", async () => {
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test never exercises the “OR” semantics it claims to cover. Because the only fixture record contains all of the requested tags, a regression that requires all tags to match would still pass here. Please add distinct records or expectations that demonstrate any-tag matches to actually validate the intended behavior.

Prompt for AI agents
Address the following comment on src/storage/drizzle.test.ts at line 203:

<comment>This new test never exercises the “OR” semantics it claims to cover. Because the only fixture record contains all of the requested tags, a regression that requires *all* tags to match would still pass here. Please add distinct records or expectations that demonstrate any-tag matches to actually validate the intended behavior.</comment>

<file context>
@@ -188,6 +188,41 @@ describe(&quot;DrizzleStore&quot;, () =&gt; {
+			expect(found[0]?.id).toBe(record.id);
+		});
+
+		it(&quot;should find all records by multiple tags&quot;, async () =&gt; {
+			const { record } = await keys.create({
+				ownerId: &quot;user_123&quot;,
</file context>

✅ Addressed in 689cac9

const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

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

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

const found = await store.findByTag("test", "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 @@ -608,7 +643,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
35 changes: 32 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, 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,35 @@ export class DrizzleStore implements Storage {
return rows.map(this.toRecord);
}

async findByTag(
tag: string | string[],
ownerId?: string
): Promise<ApiKeyRecord[]> {
const tagArray = Array.isArray(tag)
? tag.map((t) => t.toLowerCase())
: [tag.toLowerCase()];

const conditions: SQL[] = [];

if (tagArray.length > 0) {
// case insensitive tag matching
conditions.push(
sql`${this.table.metadata}->'tags' ?| ARRAY[${tagArray.join(",")}]`
);
}

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

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

return rows.map(this.toRecord);
}

async updateMetadata(
id: string,
metadata: Partial<ApiKeyMetadata>
Expand Down
35 changes: 35 additions & 0 deletions src/storage/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,41 @@ 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", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

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

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

const found = await store.findByTag("test", "user_123");
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test never creates a non-matching owner, so it would still pass even if findByTag ignored the ownerId filter. Please add a second record with the same tag but a different owner and assert it is excluded.

Prompt for AI agents
Address the following comment on src/storage/memory.test.ts at line 162:

<comment>This test never creates a non-matching owner, so it would still pass even if findByTag ignored the ownerId filter. Please add a second record with the same tag but a different owner and assert it is excluded.</comment>

<file context>
@@ -130,6 +130,41 @@ describe(&quot;MemoryStore&quot;, () =&gt; {
+				tags: [&quot;test&quot;, &quot;key&quot;, &quot;more&quot;, &quot;tags&quot;],
+			});
+
+			const found = await store.findByTag(&quot;test&quot;, &quot;user_123&quot;);
+			expect(found).toHaveLength(1);
+			expect(found[0]?.id).toBe(record.id);
</file context>

✅ Addressed in 689cac9

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
17 changes: 17 additions & 0 deletions src/storage/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ export class MemoryStore implements Storage {
);
}

async findByTag(
tag: string | string[],
ownerId?: string
): Promise<ApiKeyRecord[]> {
return Array.from(await this.keys.values()).filter((record) => {
if (ownerId && record.metadata.ownerId !== ownerId) {
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owner filter only runs when ownerId is truthy. Passing an empty string (still a valid ownerId) would bypass scoping and return other owners' keys.

Prompt for AI agents
Address the following comment on src/storage/memory.ts at line 40:

<comment>The owner filter only runs when ownerId is truthy. Passing an empty string (still a valid ownerId) would bypass scoping and return other owners&#39; keys.</comment>

<file context>
@@ -31,6 +32,23 @@ export class MemoryStore implements Storage {
+		ownerId?: string
+	): Promise&lt;ApiKeyRecord[]&gt; {
+		return Array.from(await this.keys.values()).filter((record) =&gt; {
+			if (ownerId &amp;&amp; record.metadata.ownerId !== ownerId) {
+				return false;
+			}
</file context>

✅ Addressed in 689cac9

return false;
}
const recordTags = record.metadata.tags?.map((t) => t.toLowerCase());
// case insensitive tag matching
if (Array.isArray(tag)) {
return tag.some((t) => recordTags?.includes(t.toLowerCase()));
}
return recordTags?.includes(tag.toLowerCase());
});
}

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

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", async () => {
const { record } = await keys.create({
ownerId: "user_123",
tags: ["test", "key", "more", "tags"],
});

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

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

const found = await store.findByTag("test", "user_123");
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With only one key created for owner_123, this test never verifies that the owner filter is actually applied. Because RedisStore.findByTag currently unions owner and tag sets, an owner_123 key without the tag would still be returned, yet this assertion would still pass. Please add another key that should be filtered out to confirm the owner scoping works.

Prompt for AI agents
Address the following comment on src/storage/redis.test.ts at line 199:

<comment>With only one key created for owner_123, this test never verifies that the owner filter is actually applied. Because RedisStore.findByTag currently unions owner and tag sets, an owner_123 key without the tag would still be returned, yet this assertion would still pass. Please add another key that should be filtered out to confirm the owner scoping works.</comment>

<file context>
@@ -167,6 +167,41 @@ describe(&quot;RedisStore&quot;, () =&gt; {
+				tags: [&quot;test&quot;, &quot;key&quot;, &quot;more&quot;, &quot;tags&quot;],
+			});
+
+			const found = await store.findByTag(&quot;test&quot;, &quot;user_123&quot;);
+			expect(found).toHaveLength(1);
+			expect(found[0]?.id).toBe(record.id);
</file context>

✅ Addressed in c74e819

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