{"_id":"@00akshatsinha00/convex-api-keys","name":"@00akshatsinha00/convex-api-keys","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@00akshatsinha00/convex-api-keys","description":"A convex api keys component for Convex.","repository":{"type":"git","url":"git+https://github.com/akshatsinha0/convex-api-keys.git"},"homepage":"https://github.com/akshatsinha0/convex-api-keys#readme","bugs":{"url":"https://github.com/akshatsinha0/convex-api-keys/issues"},"version":"0.1.0","license":"Apache-2.0","keywords":["convex","component"],"type":"module","scripts":{"dev":"run-p -r dev:backend dev:frontend dev:build","dev:backend":"convex dev --typecheck-components","dev:frontend":"cd example && vite --clearScreen false","dev:build":"chokidar 'tsconfig*.json' 'src/**/*.ts' -i '**/*.test.ts' -c 'npm run build:codegen' --initial","predev":"path-exists .env.local dist || (npm run build && convex dev --once)","build":"tsc --project ./tsconfig.build.json","build:codegen":"npx convex codegen --component-dir ./src/component && npm run build","build:clean":"rm -rf dist *.tsbuildinfo && npm run build:codegen","typecheck":"tsc --noEmit && tsc -p example && tsc -p example/convex","lint":"eslint .","all":"run-p -r 'dev:*' 'test:watch'","test":"vitest run --typecheck","test:watch":"vitest --typecheck --clearScreen false","test:debug":"vitest --inspect-brk --no-file-parallelism","test:coverage":"vitest run --coverage --coverage.reporter=text","preversion":"npm ci && npm run build:clean && run-p test lint typecheck","prepublishOnly":"npm whoami || npm login","alpha":"npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags","release":"npm version patch && npm publish && git push --follow-tags","version":"vim -c 'normal o' -c 'normal o## '$npm_package_version CHANGELOG.md && prettier -w CHANGELOG.md && git add CHANGELOG.md"},"exports":{"./package.json":"./package.json",".":{"types":"./dist/client/index.d.ts","default":"./dist/client/index.js"},"./react":{"types":"./dist/react/index.d.ts","default":"./dist/react/index.js"},"./test":"./src/test.ts","./_generated/component.js":{"types":"./dist/component/_generated/component.d.ts"},"./_generated/component":{"types":"./dist/component/_generated/component.d.ts"},"./convex.config.js":{"types":"./dist/component/convex.config.d.ts","default":"./dist/component/convex.config.js"},"./convex.config":{"types":"./dist/component/convex.config.d.ts","default":"./dist/component/convex.config.js"},"./unkey":{"types":"./dist/client/unkey.d.ts","default":"./dist/client/unkey.js"}},"peerDependencies":{"convex":"^1.31.7","react":"^18.3.1 || ^19.0.0","@unkey/api":">=1.0.0"},"peerDependenciesMeta":{"react":{"optional":true},"@unkey/api":{"optional":true}},"devDependencies":{"@convex-dev/eslint-plugin":"^1.1.1","@edge-runtime/vm":"^5.0.0","@eslint/eslintrc":"^3.3.3","@eslint/js":"9.39.2","@types/node":"^24.10.11","@types/react":"^19.2.13","@types/react-dom":"^19.2.3","@vitejs/plugin-react":"^5.1.3","chokidar-cli":"3.0.0","convex":"1.31.7","convex-test":"0.0.41","eslint":"9.39.2","eslint-plugin-react":"^7.37.5","eslint-plugin-react-hooks":"^7.0.1","eslint-plugin-react-refresh":"^0.5.0","globals":"^17.3.0","npm-run-all2":"8.0.4","path-exists-cli":"2.0.0","pkg-pr-new":"^0.0.63","prettier":"3.8.1","react":"^19.2.4","react-dom":"^19.2.4","typescript":"5.9.3","typescript-eslint":"8.54.0","vite":"7.3.1","vitest":"4.0.18"},"types":"./dist/client/index.d.ts","module":"./dist/client/index.js","gitHead":"8088f6c205e3a77aa33f625528ac16e96d46e0db","_id":"@00akshatsinha00/convex-api-keys@0.1.0","_nodeVersion":"24.13.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-8osbeVfYaoGRM6FuhYtGoQm6u1n5DHviaNEUx4XfVGk7CGTA3mDGKDLluwAqq51PnqNO5tM/2gkOHlaeZgNF4g==","shasum":"afaa19ed9c2bb33005251dd32d1925af2df30ebe","tarball":"https://registry.npmjs.org/@00akshatsinha00/convex-api-keys/-/convex-api-keys-0.1.0.tgz","fileCount":237,"unpackedSize":585830,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQD5Vz5TeEfrkVBPeXwoMl5gX5Qp+rSv5qWwIyPpWzLUCwIhAKgW+oSKWSvoDz4RQQN5dj0CVQyNMS/6sCLjpI7LNLxT"}]},"_npmUser":{"name":"00akshatsinha00","email":"akshatsinhasramhardy@gmail.com"},"directories":{},"maintainers":[{"name":"00akshatsinha00","email":"akshatsinhasramhardy@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/convex-api-keys_0.1.0_1770894406624_0.0767057601460861"},"_hasShrinkwrap":false}},"time":{"created":"2026-02-12T11:06:46.495Z","0.1.0":"2026-02-12T11:06:46.827Z","modified":"2026-02-12T11:06:47.069Z"},"maintainers":[{"name":"00akshatsinha00","email":"akshatsinhasramhardy@gmail.com"}],"description":"A convex api keys component for Convex.","homepage":"https://github.com/akshatsinha0/convex-api-keys#readme","keywords":["convex","component"],"repository":{"type":"git","url":"git+https://github.com/akshatsinha0/convex-api-keys.git"},"bugs":{"url":"https://github.com/akshatsinha0/convex-api-keys/issues"},"license":"Apache-2.0","readme":"# Convex API Keys\r\n\r\n[![npm version](https://badge.fury.io/js/@00akshatsinha00%2Fconvex-api-keys.svg)](https://badge.fury.io/js/@00akshatsinha00%2Fconvex-api-keys)\r\n\r\nA native, zero-dependency Convex component for API key management. Handles key generation, SHA-256 hashing, verification, rate limiting, RBAC, usage credits, and audit logging -- all within Convex's transactional guarantees.\r\n\r\nFound a bug? Feature request? [File it here](https://github.com/akshatsinha0/convex-api-keys/issues).\r\n\r\n## Why This Component?\r\n\r\nMost API key solutions require external services, webhook plumbing, and separate databases. This component runs entirely inside Convex, giving you:\r\n\r\n- **Transactional verification** -- key lookup, rate limit check, credit decrement, and audit log write all happen in a single atomic mutation\r\n- **Reactive dashboards** -- use Convex queries to build real-time key usage dashboards that update automatically\r\n- **Zero infrastructure** -- no Redis for rate limiting, no separate analytics pipeline, no webhook endpoints to maintain\r\n- **Component isolation** -- all data lives in the component's private tables; your app tables stay clean\r\n\r\n## Features\r\n\r\n- Cryptographically secure key generation (configurable entropy, base62 encoded)\r\n- SHA-256 hashed storage (plaintext keys never persist)\r\n- Sliding window rate limiting with per-key and per-owner shared overrides\r\n- Usage credits with automatic refill (hourly/daily/weekly/monthly)\r\n- Role-based access control (RBAC) with permissions and roles\r\n- Key rotation with configurable grace periods\r\n- Full audit logging with action type filtering\r\n- Cursor-based pagination for key listings\r\n- Real-time reactive queries for dashboards\r\n- Analytics rollups (hourly + daily) with time-bucketed queries\r\n- Top keys by usage ranking\r\n- Multi-tenant support via namespaces and owner isolation\r\n- Configurable key bytes, log retention, and rollup intervals\r\n\r\n## Installation\r\n\r\n```sh\r\nnpm install @00akshatsinha00/convex-api-keys\r\n```\r\n\r\nRegister the component in your `convex/convex.config.ts`:\r\n\r\n```ts\r\nimport { defineApp } from \"convex/server\";\r\nimport apiKeys from \"@00akshatsinha00/convex-api-keys/convex.config\";\r\n\r\nconst app = defineApp();\r\napp.use(apiKeys, { name: \"apiKeys\" });\r\nexport default app;\r\n```\r\n\r\n## Quick Start\r\n\r\n### Direct component access\r\n\r\n```ts\r\nimport { mutation, query } from \"./_generated/server\";\r\nimport { components } from \"./_generated/api\";\r\nimport { v } from \"convex/values\";\r\n\r\nexport const createKey = mutation({\r\n  args: { name: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n    return await ctx.runMutation(components.apiKeys.lib.create, {\r\n      ownerId: userId,\r\n      name: args.name,\r\n      namespace: \"default\",\r\n    });\r\n  },\r\n});\r\n\r\nexport const verifyKey = mutation({\r\n  args: { key: v.string() },\r\n  handler: async (ctx, args) => {\r\n    return await ctx.runMutation(components.apiKeys.lib.verify, {\r\n      key: args.key,\r\n    });\r\n  },\r\n});\r\n```\r\n\r\n### Client SDK (class-based)\r\n\r\n```ts\r\nimport { ApiKeys } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"production\",\r\n  defaultPrefix: \"sk_live_\",\r\n  keyBytes: 32,              // configurable key entropy (default: 32)\r\n  logRetentionDays: 90,      // auto-applied to purgeVerificationLogs\r\n});\r\n\r\nexport const createKey = mutation({\r\n  args: { name: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n    return await apiKeys.create(ctx, { ownerId: userId, name: args.name });\r\n  },\r\n});\r\n\r\nexport const verifyKey = mutation({\r\n  args: { key: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const result = await apiKeys.verify(ctx, { key: args.key });\r\n    // Static class methods for permission checking\r\n    if (result.valid && ApiKeys.hasPermission(result, \"write:data\")) {\r\n      // authorized\r\n    }\r\n    return result;\r\n  },\r\n});\r\n```\r\n\r\n---\r\n\r\n## Real-World Scenarios\r\n\r\n### Scenario 1: SaaS API with Tiered Plans\r\n\r\nYou run a SaaS product and want to issue API keys to customers on different pricing tiers. Free users get 100 requests/day, Pro users get 10,000, and Enterprise gets unlimited.\r\n\r\n```ts\r\n// convex/apiKeyManager.ts\r\nimport { ApiKeys } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\nimport { mutation, query } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"api-v1\",\r\n  defaultPrefix: \"sk_live_\",\r\n});\r\n\r\n// Issue a key when a customer signs up or upgrades\r\nexport const issueKeyForPlan = mutation({\r\n  args: {\r\n    plan: v.union(v.literal(\"free\"), v.literal(\"pro\"), v.literal(\"enterprise\")),\r\n  },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n\r\n    const planConfig = {\r\n      free:       { remaining: 100,   refill: { amount: 100,   interval: \"daily\" as const } },\r\n      pro:        { remaining: 10000, refill: { amount: 10000, interval: \"daily\" as const } },\r\n      enterprise: {},  // no usage cap\r\n    };\r\n\r\n    const config = planConfig[args.plan];\r\n\r\n    return await apiKeys.create(ctx, {\r\n      ownerId: userId,\r\n      name: `${args.plan}-key`,\r\n      meta: { plan: args.plan },\r\n      ...config,\r\n      ratelimit: { limit: 60, duration: 60000 },  // 60 req/min burst protection for all tiers\r\n    });\r\n  },\r\n});\r\n\r\n// Verify on every API request\r\nexport const handleApiRequest = mutation({\r\n  args: { key: v.string(), endpoint: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const result = await apiKeys.verify(ctx, {\r\n      key: args.key,\r\n      tags: { endpoint: args.endpoint },\r\n    });\r\n\r\n    if (!result.valid) {\r\n      return { error: result.code, message: result.message };\r\n    }\r\n\r\n    return {\r\n      authorized: true,\r\n      remaining: result.remaining,  // undefined for Enterprise (unlimited)\r\n      plan: result.meta?.plan,\r\n    };\r\n  },\r\n});\r\n\r\n// Dashboard query: show customer their usage\r\nexport const myKeyUsage = query({\r\n  args: { keyId: v.string() },\r\n  handler: async (ctx, args) => {\r\n    return await apiKeys.getUsageStats(ctx, { keyId: args.keyId });\r\n    // Returns: { total, valid, rateLimited, usageExceeded, expired, revoked, disabled, notFound }\r\n  },\r\n});\r\n```\r\n\r\n### Scenario 2: Multi-Service RBAC (Microservice Gateway)\r\n\r\nYou have several internal services (billing, users, analytics) behind a gateway. Each API key should only access authorized services.\r\n\r\n```ts\r\n// convex/gateway.ts\r\nimport { ApiKeys, hasPermission, hasAllPermissions } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\nimport { mutation } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"gateway\",\r\n  defaultPrefix: \"gw_\",\r\n});\r\n\r\n// Admin: set up permissions and roles once\r\nexport const bootstrapRbac = mutation({\r\n  handler: async (ctx) => {\r\n    // Create granular permissions\r\n    await apiKeys.createPermission(ctx, { name: \"billing:read\",  description: \"View invoices\" });\r\n    await apiKeys.createPermission(ctx, { name: \"billing:write\", description: \"Create charges\" });\r\n    await apiKeys.createPermission(ctx, { name: \"users:read\",    description: \"List users\" });\r\n    await apiKeys.createPermission(ctx, { name: \"users:write\",   description: \"Manage users\" });\r\n    await apiKeys.createPermission(ctx, { name: \"analytics:read\", description: \"View metrics\" });\r\n\r\n    // Bundle permissions into roles\r\n    await apiKeys.createRole(ctx, {\r\n      name: \"billing-admin\",\r\n      permissions: [\"billing:read\", \"billing:write\"],\r\n    });\r\n    await apiKeys.createRole(ctx, {\r\n      name: \"readonly\",\r\n      permissions: [\"billing:read\", \"users:read\", \"analytics:read\"],\r\n    });\r\n    await apiKeys.createRole(ctx, {\r\n      name: \"superadmin\",\r\n      permissions: [\"billing:read\", \"billing:write\", \"users:read\", \"users:write\", \"analytics:read\"],\r\n    });\r\n  },\r\n});\r\n\r\n// Issue a key with specific roles\r\nexport const issueServiceKey = mutation({\r\n  args: { serviceName: v.string(), roles: v.array(v.string()) },\r\n  handler: async (ctx, args) => {\r\n    const result = await apiKeys.create(ctx, {\r\n      ownerId: args.serviceName,\r\n      name: `${args.serviceName}-key`,\r\n    });\r\n\r\n    // Assign roles to the newly created key\r\n    await apiKeys.assignRoles(ctx, {\r\n      keyId: result.keyId,\r\n      roles: args.roles,\r\n    });\r\n\r\n    return result;\r\n  },\r\n});\r\n\r\n// Gateway: verify and check permissions per endpoint\r\nexport const routeRequest = mutation({\r\n  args: { key: v.string(), service: v.string(), action: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const result = await apiKeys.verify(ctx, { key: args.key });\r\n\r\n    if (!result.valid) {\r\n      return { status: 401, error: result.code };\r\n    }\r\n\r\n    const requiredPermission = `${args.service}:${args.action}`;\r\n    if (!hasPermission(result, requiredPermission)) {\r\n      return { status: 403, error: \"INSUFFICIENT_PERMISSIONS\" };\r\n    }\r\n\r\n    return { status: 200, keyId: result.keyId, permissions: result.permissions };\r\n  },\r\n});\r\n```\r\n\r\n### Scenario 3: Key Rotation for PCI/SOC2 Compliance\r\n\r\nYour security policy requires rotating API keys every 90 days. You want zero-downtime rotation with a grace period so clients can migrate.\r\n\r\n```ts\r\n// convex/compliance.ts\r\nimport { ApiKeys, isKeyExpiringSoon, calculateExpiration } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\nimport { mutation, query } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"production\",\r\n  defaultPrefix: \"sk_live_\",\r\n});\r\n\r\n// Create key with 90-day expiry\r\nexport const createComplianceKey = mutation({\r\n  args: { name: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n    return await apiKeys.create(ctx, {\r\n      ownerId: userId,\r\n      name: args.name,\r\n      expires: calculateExpiration(90),  // 90 days from now\r\n    });\r\n  },\r\n});\r\n\r\n// Rotate a key with 48-hour grace period -- both old and new key work during grace\r\nexport const rotateKey = mutation({\r\n  args: { keyId: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n\r\n    const key = await apiKeys.getKey(ctx, { keyId: args.keyId });\r\n    if (!key || key.ownerId !== userId) {\r\n      throw new Error(\"Unauthorized\");\r\n    }\r\n\r\n    // Old key stays valid for 48 hours; new key is returned immediately\r\n    return await apiKeys.rotate(ctx, {\r\n      keyId: args.keyId,\r\n      gracePeriodMs: 48 * 60 * 60 * 1000,  // 48 hours\r\n    });\r\n  },\r\n});\r\n\r\n// Dashboard: show keys that need rotation soon\r\nexport const keysNeedingRotation = query({\r\n  args: {},\r\n  handler: async (ctx) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n    const keys = await apiKeys.getKeysByOwner(ctx, { ownerId: userId });\r\n\r\n    return keys.filter((k) => isKeyExpiringSoon(k.expires, 14));  // expiring within 14 days\r\n  },\r\n});\r\n```\r\n\r\n### Scenario 4: Webhook Endpoint Protection\r\n\r\nYou expose a webhook URL that external services call. You want to rate limit incoming webhooks and track which integration sent each request.\r\n\r\n```ts\r\n// convex/webhooks.ts\r\nimport { ApiKeys } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\nimport { httpAction, mutation } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"webhooks\",\r\n  defaultPrefix: \"whk_\",\r\n});\r\n\r\n// Issue a webhook key for each integration partner\r\nexport const createWebhookKey = mutation({\r\n  args: {\r\n    partnerName: v.string(),\r\n    maxPerMinute: v.number(),\r\n  },\r\n  handler: async (ctx, args) => {\r\n    return await apiKeys.create(ctx, {\r\n      ownerId: args.partnerName,\r\n      name: `${args.partnerName}-webhook`,\r\n      meta: { type: \"webhook\", partner: args.partnerName },\r\n      ratelimit: { limit: args.maxPerMinute, duration: 60000 },\r\n    });\r\n  },\r\n});\r\n\r\n// HTTP action: receive webhook, verify key from header\r\nexport const receiveWebhook = httpAction(async (ctx, request) => {\r\n  const authHeader = request.headers.get(\"Authorization\");\r\n  if (!authHeader?.startsWith(\"Bearer \")) {\r\n    return new Response(\"Missing API key\", { status: 401 });\r\n  }\r\n\r\n  const key = authHeader.slice(7);\r\n  const result = await ctx.runMutation(components.apiKeys.lib.verify, {\r\n    key,\r\n    ip: request.headers.get(\"X-Forwarded-For\") ?? undefined,\r\n    tags: { source: \"webhook\", path: new URL(request.url).pathname },\r\n  });\r\n\r\n  if (!result.valid) {\r\n    const status = result.code === \"RATE_LIMITED\" ? 429 : 401;\r\n    return new Response(result.message, { status });\r\n  }\r\n\r\n  // Process the webhook payload...\r\n  return new Response(\"OK\", { status: 200 });\r\n});\r\n```\r\n\r\n### Scenario 5: Admin Dashboard with Analytics\r\n\r\nBuild a reactive admin panel showing API key health metrics, usage breakdowns, and audit trails.\r\n\r\n```ts\r\n// convex/adminDashboard.ts\r\nimport { ApiKeys } from \"@00akshatsinha00/convex-api-keys\";\r\nimport { components } from \"./_generated/api\";\r\nimport { query } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"production\",\r\n});\r\n\r\n// Overall health: total keys, active vs revoked, success rate\r\nexport const namespaceHealth = query({\r\n  handler: async (ctx) => {\r\n    return await apiKeys.getOverallStats(ctx, { namespace: \"production\" });\r\n    // Returns: { totalKeys, activeKeys, disabledKeys, expiredKeys,\r\n    //            revokedKeys, totalVerifications, successRate }\r\n  },\r\n});\r\n\r\n// Per-owner usage breakdown (e.g., show each customer's consumption)\r\nexport const customerUsage = query({\r\n  args: { ownerId: v.string() },\r\n  handler: async (ctx, args) => {\r\n    return await apiKeys.getUsageByOwner(ctx, {\r\n      ownerId: args.ownerId,\r\n      period: \"day\",\r\n    });\r\n  },\r\n});\r\n\r\n// Audit trail for compliance review\r\nexport const recentAuditEvents = query({\r\n  args: { limit: v.optional(v.number()) },\r\n  handler: async (ctx, args) => {\r\n    return await apiKeys.getAuditLog(ctx, { limit: args.limit ?? 50 });\r\n    // Returns: [{ action, actorId, targetKeyHash, timestamp, details }]\r\n  },\r\n});\r\n\r\n// Verification history for a specific key (useful for debugging)\r\nexport const keyVerificationHistory = query({\r\n  args: { keyId: v.string(), since: v.optional(v.number()) },\r\n  handler: async (ctx, args) => {\r\n    return await apiKeys.getVerificationLog(ctx, {\r\n      keyId: args.keyId,\r\n      since: args.since,\r\n      limit: 100,\r\n    });\r\n    // Returns: [{ keyHash, timestamp, success, code, remaining, rateLimitRemaining, tags, ip }]\r\n  },\r\n});\r\n```\r\n\r\n### Scenario 6: Scheduled Maintenance and Cleanup\r\n\r\nSet up automatic cleanup of expired keys and old logs to keep your database lean.\r\n\r\n```ts\r\n// convex/crons.ts\r\nimport { cronJobs } from \"convex/server\";\r\nimport { components } from \"./_generated/api\";\r\n\r\nconst crons = cronJobs();\r\n\r\n// Every hour: expire keys past their expiration date\r\ncrons.interval(\"expire stale keys\", { hours: 1 }, components.apiKeys.lib.expireKeys, {\r\n  namespace: \"production\",\r\n});\r\n\r\n// Every 6 hours: roll up raw verification logs into hourly analytics buckets\r\ncrons.interval(\"rollup analytics\", { hours: 6 }, components.apiKeys.lib.rollupAnalytics, {\r\n  namespace: \"production\",\r\n  period: \"hourly\",\r\n  olderThan: 6 * 60 * 60 * 1000,  // roll up logs older than 6 hours\r\n});\r\n\r\n// Daily: purge verification logs older than 30 days\r\ncrons.interval(\"cleanup old logs\", { hours: 24 }, components.apiKeys.lib.cleanupLogs, {\r\n  olderThanMs: 30 * 24 * 60 * 60 * 1000,\r\n});\r\n\r\nexport default crons;\r\n```\r\n\r\n---\r\n\r\n## Unkey Integration (Optional)\r\n\r\nThe component works fully standalone with zero dependencies. Optionally, you can use [Unkey](https://unkey.dev) as the key management engine while Convex provides reactive state, unlimited audit trails, and analytics rollups.\r\n\r\n### Why use Unkey mode?\r\n\r\n- **Battle-tested key infrastructure** -- Unkey handles key generation, verification, rate limiting at scale\r\n- **Convex reactive layer** -- real-time dashboards, subscriptions, and audit logging on top of Unkey\r\n- **No webhooks needed** -- Unkey has no event system; this component fills that gap with local logging\r\n- **Same queries** -- `listKeys`, `getUsageStats`, `getAuditLog` work identically in both modes\r\n\r\n### Installation\r\n\r\n```sh\r\nnpm install @unkey/api\r\n```\r\n\r\n### Setup\r\n\r\n```ts\r\nimport { Unkey } from \"@unkey/api\";\r\nimport { UnkeyApiKeys } from \"@00akshatsinha00/convex-api-keys/unkey\";\r\nimport { components } from \"./_generated/api\";\r\n\r\n// Construct the Unkey client in your app code (components can't access process.env)\r\nconst unkeyClient = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });\r\n\r\nconst apiKeys = new UnkeyApiKeys(\r\n  components.apiKeys,\r\n  { apiId: process.env.UNKEY_API_ID!, defaultNamespace: \"production\" },\r\n  unkeyClient\r\n);\r\n```\r\n\r\n### Usage\r\n\r\nWrite operations (create, verify, revoke, update) must be called from **actions** since they make HTTP calls to Unkey:\r\n\r\n```ts\r\nimport { action } from \"./_generated/server\";\r\nimport { v } from \"convex/values\";\r\n\r\nexport const createKey = action({\r\n  args: { name: v.string() },\r\n  handler: async (ctx, args) => {\r\n    const userId = (await ctx.auth.getUserIdentity())!.subject;\r\n    // 1. Calls Unkey API to generate key\r\n    // 2. Mirrors result into component tables via mutation\r\n    return await apiKeys.create(ctx, { ownerId: userId, name: args.name });\r\n  },\r\n});\r\n\r\nexport const verifyKey = action({\r\n  args: { key: v.string() },\r\n  handler: async (ctx, args) => {\r\n    // 1. Calls Unkey API to verify\r\n    // 2. Logs verification result into component tables\r\n    return await apiKeys.verify(ctx, { key: args.key });\r\n  },\r\n});\r\n```\r\n\r\nRead operations work from queries (no Unkey call needed):\r\n\r\n```ts\r\nimport { query } from \"./_generated/server\";\r\n\r\nexport const listKeys = query({\r\n  handler: async (ctx) => {\r\n    return await apiKeys.listKeys(ctx);\r\n  },\r\n});\r\n```\r\n\r\n### Architecture: Unkey Mode\r\n\r\n```\r\nYour Convex App\r\n  │\r\n  ├── action: apiKeys.create(ctx, args)\r\n  │     ├── 1. Unkey SDK → keys.create() → { key, keyId }\r\n  │     └── 2. ctx.runMutation → component.lib.importKey()\r\n  │           └── ctx.db.insert(\"keys\", { unkeyKeyId, hash, ... })\r\n  │\r\n  ├── action: apiKeys.verify(ctx, args)\r\n  │     ├── 1. Unkey SDK → keys.verifyKey() → { valid, code, remaining }\r\n  │     └── 2. ctx.runMutation → component.lib.logExternalVerification()\r\n  │           └── ctx.db.insert(\"verificationLogs\", { ... })\r\n  │\r\n  └── query: apiKeys.listKeys(ctx)  ← pure Convex query, no Unkey call\r\n        └── ctx.runQuery → component.lib.listKeys()\r\n```\r\n\r\n### When to Use Which\r\n\r\n| Feature | Native Mode | Unkey Mode |\r\n|---------|-------------|------------|\r\n| Dependencies | Zero | `@unkey/api` |\r\n| Key generation | Convex (crypto.subtle) | Unkey API |\r\n| Verification | Convex mutation (atomic) | Unkey API + local log |\r\n| Rate limiting | Convex sliding window | Unkey + local mirror |\r\n| Dashboards | Reactive queries | Reactive queries (same) |\r\n| Audit trail | Full | Full |\r\n| Write context | Mutation | Action (HTTP calls) |\r\n| Latency | Single Convex call | Convex + Unkey round-trip |\r\n| Best for | Self-contained apps | Apps already using Unkey |\r\n\r\n---\r\n\r\n## API Reference\r\n\r\n### Key Lifecycle\r\n\r\n| Method                    | Type       | Description                                                                   |\r\n| :------------------------ | :--------- | :---------------------------------------------------------------------------- |\r\n| `create(ctx, args)`       | `mutation` | Generate a new API key with optional credits, rate limit, RBAC, and keyBytes  |\r\n| `verify(ctx, args)`       | `mutation` | Verify a key -- checks revoked, disabled, expired, grace, refill, credits, per-key + per-owner rate limit, RBAC |\r\n| `revoke(ctx, args)`       | `mutation` | Revoke a key (`soft: true` keeps record, `soft: false` hard-deletes)          |\r\n| `update(ctx, args)`       | `mutation` | Update key properties (name, meta, expiry, credits, rate limit, enabled)      |\r\n| `rotate(ctx, args)`       | `mutation` | Generate a replacement key with optional grace period for the old key         |\r\n\r\n#### `create` options\r\n\r\n```ts\r\nawait apiKeys.create(ctx, {\r\n  ownerId: \"user_123\",          // required: who owns this key\r\n  name: \"Production Key\",       // display name (default: \"Untitled Key\")\r\n  prefix: \"sk_live_\",           // key prefix for visual identification\r\n  namespace: \"production\",      // logical grouping / environment\r\n  meta: { plan: \"pro\" },        // arbitrary metadata returned on verify\r\n  expires: Date.now() + 90 * 24 * 60 * 60 * 1000,  // optional expiration timestamp\r\n  remaining: 10000,             // optional usage credit cap\r\n  refill: { amount: 10000, interval: \"daily\" },     // auto-refill credits\r\n  ratelimit: { limit: 100, duration: 60000 },       // 100 req / 60s sliding window\r\n  roles: [\"admin\"],             // role names to assign\r\n  permissions: [\"keys:write\"],  // direct permission names to assign\r\n  environment: \"staging\",       // optional environment tag\r\n  keyBytes: 32,                 // random bytes for key entropy (default: 32)\r\n});\r\n// Returns: { key: \"sk_live_abc123...\", keyId: \"j57abc...\" }\r\n```\r\n\r\n#### `verify` response\r\n\r\n```ts\r\nconst result = await apiKeys.verify(ctx, { key, tags: { endpoint: \"/api/data\" }, ip: \"1.2.3.4\" });\r\n```\r\n\r\n| Field              | Type                              | Description                                              |\r\n| :----------------- | :-------------------------------- | :------------------------------------------------------- |\r\n| `valid`            | `boolean`                         | Whether the key passed all checks                        |\r\n| `code`             | `OutcomeCode`                     | `\"VALID\"`, `\"REVOKED\"`, `\"EXPIRED\"`, `\"RATE_LIMITED\"`, `\"USAGE_EXCEEDED\"`, `\"DISABLED\"`, `\"NOT_FOUND\"`, `\"ROTATION_GRACE_EXPIRED\"` |\r\n| `keyId`            | `string?`                         | Internal key document ID                                 |\r\n| `ownerId`          | `string?`                         | Owner of the key                                         |\r\n| `meta`             | `object?`                         | Metadata stored on create                                |\r\n| `remaining`        | `number?`                         | Credits left (`undefined` if unlimited)                  |\r\n| `ratelimit`        | `{ remaining, reset }?`           | Rate limit state (only present when rate limited)        |\r\n| `permissions`      | `string[]`                        | Resolved permission names (direct + role-inherited)      |\r\n| `roles`            | `string[]`                        | Resolved role names assigned to this key                 |\r\n| `message`          | `string?`                         | Human-readable status message                            |\r\n\r\n---\r\n\r\n### Queries\r\n\r\n| Method                            | Type    | Description                                                                        |\r\n| :-------------------------------- | :------ | :--------------------------------------------------------------------------------- |\r\n| `listKeys(ctx, args?)`            | `query` | Paginated key listing by namespace/owner. Returns `{ keys, cursor?, hasMore }`     |\r\n| `getKey(ctx, { keyId })`          | `query` | Get a single key by ID. Returns `KeyInfo \\| null`                                  |\r\n| `getKeysByOwner(ctx, { ownerId })` | `query` | Get all keys for an owner across all namespaces                                    |\r\n\r\n#### `listKeys` -- cursor-based pagination\r\n\r\n```ts\r\n// First page\r\nconst page1 = await apiKeys.listKeys(ctx, { namespace: \"prod\", limit: 25 });\r\n// page1 = { keys: [...], cursor: \"abc...\", hasMore: true }\r\n\r\n// Next page\r\nconst page2 = await apiKeys.listKeys(ctx, { namespace: \"prod\", limit: 25, cursor: page1.cursor });\r\n// page2 = { keys: [...], cursor: undefined, hasMore: false }\r\n```\r\n\r\n| Arg           | Type      | Description                                     |\r\n| :------------ | :-------- | :---------------------------------------------- |\r\n| `namespace`   | `string?` | Filter by namespace                              |\r\n| `ownerId`     | `string?` | Filter by owner                                  |\r\n| `limit`       | `number?` | Page size (default: 100)                         |\r\n| `cursor`      | `string?` | Cursor from previous `listKeys` result           |\r\n\r\n---\r\n\r\n### RBAC\r\n\r\n| Method                               | Type       | Description                                         |\r\n| :----------------------------------- | :--------- | :-------------------------------------------------- |\r\n| `createPermission(ctx, args)`        | `mutation` | Create a named permission (e.g., `\"billing:read\"`)  |\r\n| `createRole(ctx, args)`              | `mutation` | Create a role bundling multiple permissions          |\r\n| `assignRoles(ctx, args)`             | `mutation` | Replace a key's role assignments                     |\r\n| `assignPermissions(ctx, args)`       | `mutation` | Replace a key's direct permission assignments        |\r\n| `listPermissions(ctx)`               | `query`    | List all registered permissions                      |\r\n| `listRoles(ctx)`                     | `query`    | List all registered roles with their permissions     |\r\n| `deletePermission(ctx, args)`        | `mutation` | Delete a permission by ID                            |\r\n| `deleteRole(ctx, args)`              | `mutation` | Delete a role by ID                                  |\r\n\r\n---\r\n\r\n### Analytics\r\n\r\n| Method                                    | Type    | Description                                                                                 |\r\n| :---------------------------------------- | :------ | :------------------------------------------------------------------------------------------ |\r\n| `getUsageStats(ctx, args)`                | `query` | Per-key verification breakdown by outcome code. Supports `period: \"hour\" \\| \"day\"` for rollup data |\r\n| `getUsageByOwner(ctx, args)`              | `query` | Aggregated stats across all of an owner's keys. Supports `period` param                     |\r\n| `getTopKeysByUsage(ctx, args)`            | `query` | Top N keys by total verification count within a namespace (sorted descending)               |\r\n| `getVerificationsOverTime(ctx, args)`     | `query` | Time-bucketed verification data (`{ timestamp, total, valid, failed }[]`). Filter by key or namespace |\r\n| `getOverallStats(ctx, { namespace })`     | `query` | Namespace-level health: total/active/disabled/expired/revoked keys, success rate            |\r\n| `getAuditLog(ctx, args?)`                 | `query` | Audit trail filterable by `keyId`, `actorId`, `actionType`, `since`, and `limit`            |\r\n| `getVerificationLog(ctx, args)`           | `query` | Verification history for a specific key with `since` and `limit`                            |\r\n\r\n#### `getTopKeysByUsage`\r\n\r\n```ts\r\nconst top = await apiKeys.getTopKeysByUsage(ctx, { namespace: \"prod\", limit: 10 });\r\n// [{ keyHash, keyId, name, ownerId, total, valid }]\r\n```\r\n\r\n#### `getVerificationsOverTime`\r\n\r\n```ts\r\nconst buckets = await apiKeys.getVerificationsOverTime(ctx, {\r\n  namespace: \"prod\",    // or keyId for per-key data\r\n  period: \"hour\",       // \"hour\" | \"day\"\r\n  since: Date.now() - 7 * 24 * 60 * 60 * 1000,  // last 7 days\r\n});\r\n// [{ timestamp, total, valid, failed }]\r\n```\r\n\r\n#### `getAuditLog` filters\r\n\r\n```ts\r\nawait apiKeys.getAuditLog(ctx, {\r\n  keyId: \"...\",           // filter by key\r\n  actorId: \"user_123\",   // filter by actor\r\n  actionType: \"key.created\",  // filter by action type\r\n  since: Date.now() - 86400000,  // events after this timestamp\r\n  limit: 50,\r\n});\r\n```\r\n\r\n---\r\n\r\n### Rate Limiting\r\n\r\n| Method                                     | Type       | Description                                                                 |\r\n| :----------------------------------------- | :--------- | :-------------------------------------------------------------------------- |\r\n| `checkRateLimit(ctx, args)`                | `mutation` | Standalone rate limit check (useful outside the verify flow)                |\r\n| `setRateLimitOverride(ctx, args)`          | `mutation` | Override a specific key's default rate limit (e.g., premium tier bump)      |\r\n| `deleteRateLimitOverride(ctx, args)`       | `mutation` | Remove a key override, reverting to the key's default limit                 |\r\n| `setOwnerRateLimit(ctx, args)`             | `mutation` | Set a shared rate limit across ALL keys belonging to an owner               |\r\n| `deleteOwnerRateLimit(ctx, args)`          | `mutation` | Remove the shared owner rate limit                                          |\r\n| `getRateLimitOverrides(ctx, { namespace })` | `query`   | List all overrides (key-level and owner-level) for a namespace              |\r\n\r\n#### Per-owner shared rate limits\r\n\r\nWhen any key owned by user X is verified, the shared owner limit for user X is decremented. This enforces a global request budget across all of an owner's keys.\r\n\r\n```ts\r\n// Set: user_123 can make 1000 total requests/hour across ALL their keys\r\nawait apiKeys.setOwnerRateLimit(ctx, {\r\n  ownerId: \"user_123\",\r\n  namespace: \"production\",\r\n  limit: 1000,\r\n  duration: 3600000,  // 1 hour\r\n});\r\n\r\n// Remove\r\nawait apiKeys.deleteOwnerRateLimit(ctx, {\r\n  ownerId: \"user_123\",\r\n  namespace: \"production\",\r\n});\r\n```\r\n\r\n---\r\n\r\n### Admin / Maintenance\r\n\r\n| Method                              | Type                | Description                                                      |\r\n| :---------------------------------- | :------------------ | :--------------------------------------------------------------- |\r\n| `purgeExpiredKeys(ctx, args)`       | `mutation`          | Hard-delete expired keys older than a threshold                  |\r\n| `purgeVerificationLogs(ctx, args?)` | `mutation`          | Delete old verification logs (uses `logRetentionDays` from config) |\r\n| `expireKeys`                        | `internal mutation` | Mark expired keys as disabled (cron: every 1 hour)              |\r\n| `cleanupLogs`                       | `internal mutation` | Remove verification logs older than 90 days (cron: every 24 hours) |\r\n| `rollupAnalytics`                   | `internal mutation` | Aggregate raw logs into hourly buckets (cron: every 1 hour)     |\r\n| `rollupDaily`                       | `internal mutation` | Aggregate hourly rollups into daily buckets (cron: every 24 hours) |\r\n\r\n---\r\n\r\n## Helper Functions\r\n\r\nStateless utilities for working with verification results. Available as both **standalone imports** and **static class methods**:\r\n\r\n```ts\r\n// Standalone imports\r\nimport {\r\n  hasPermission,       // hasPermission(result, \"billing:read\") → boolean\r\n  hasAnyPermission,    // hasAnyPermission(result, [\"a\", \"b\"]) → boolean\r\n  hasAllPermissions,   // hasAllPermissions(result, [\"a\", \"b\"]) → boolean\r\n  hasRole,             // hasRole(result, \"admin\") → boolean\r\n  isRateLimited,       // result.code === \"RATE_LIMITED\"\r\n  isExpired,           // result.code === \"EXPIRED\"\r\n  isRevoked,           // result.code === \"REVOKED\"\r\n  formatKeyHint,       // \"sk_live_...xyz1\" -- safe for display in UIs\r\n  calculateExpiration, // calculateExpiration(90) → timestamp 90 days from now\r\n  isKeyExpiringSoon,   // true if key expires within N days (default 7)\r\n} from \"@00akshatsinha00/convex-api-keys\";\r\n\r\n// Or use as static class methods\r\nApiKeys.hasPermission(result, \"billing:read\");\r\nApiKeys.hasAnyPermission(result, [\"billing:read\", \"billing:write\"]);\r\nApiKeys.hasAllPermissions(result, [\"billing:read\", \"billing:write\"]);\r\nApiKeys.hasRole(result, \"admin\");\r\n```\r\n\r\n| Helper                | Signature                                          | Description                             |\r\n| :-------------------- | :------------------------------------------------- | :-------------------------------------- |\r\n| `hasPermission`       | `(result, permission) → boolean`                   | Check for a single permission           |\r\n| `hasAnyPermission`    | `(result, permissions[]) → boolean`                | At least one permission present         |\r\n| `hasAllPermissions`   | `(result, permissions[]) → boolean`                | All permissions present                 |\r\n| `hasRole`             | `(result, role) → boolean`                         | Check for a single role                 |\r\n| `isRateLimited`       | `(result) → boolean`                               | `code === \"RATE_LIMITED\"`               |\r\n| `isExpired`           | `(result) → boolean`                               | `code === \"EXPIRED\"`                    |\r\n| `isRevoked`           | `(result) → boolean`                               | `code === \"REVOKED\"`                    |\r\n| `formatKeyHint`       | `(key) → string`                                   | Redact middle of key for safe display   |\r\n| `calculateExpiration` | `(days) → number`                                  | Timestamp N days from now               |\r\n| `isKeyExpiringSoon`   | `(expiresAt, daysThreshold?) → boolean`            | True if expiring within threshold       |\r\n\r\n---\r\n\r\n## Architecture\r\n\r\n### System Overview\r\n\r\n```\r\n┌──────────────────────────────────────────────────────────┐\r\n│                     Your Convex App                      │\r\n│  ┌───────────┐  ┌──────────┐  ┌──────────────┐          │\r\n│  │ Mutations  │  │ Queries  │  │   Actions    │          │\r\n│  └─────┬─────┘  └────┬─────┘  └──────┬───────┘          │\r\n│        │              │               │                  │\r\n│        └──────┬───────┘     ┌─────────┘                  │\r\n│     ApiKeys   │             │  UnkeyApiKeys (optional)   │\r\n│     SDK       ▼             ▼                            │\r\n│  ┌──────────────────────────────────────────────────┐    │\r\n│  │           convex-api-keys Component              │    │\r\n│  │  ┌──────┐ ┌────────┐ ┌──────┐ ┌────────┐        │    │\r\n│  │  │ Keys │ │ Verify │ │ RBAC │ │Analytics│        │    │\r\n│  │  └──┬───┘ └───┬────┘ └──┬───┘ └───┬────┘        │    │\r\n│  │     │         │         │         │              │    │\r\n│  │     └─────────┴─────────┴─────────┘              │    │\r\n│  │                    │                             │    │\r\n│  │     ┌──────────────┼──────────────┐              │    │\r\n│  │     ▼              ▼              ▼              │    │\r\n│  │  ┌──────┐   ┌────────────┐  ┌──────────┐        │    │\r\n│  │  │ keys │   │ vLogs/audit│  │ rateLimits│        │    │\r\n│  │  └──────┘   └────────────┘  └──────────┘        │    │\r\n│  └──────────────────────────────────────────────────┘    │\r\n│                                                          │\r\n│  Optional: Actions ──HTTP──▶ Unkey API (external)        │\r\n└──────────────────────────────────────────────────────────┘\r\n```\r\n\r\n### Key Verification Flow\r\n\r\nEvery `verify()` call runs atomically in a single Convex mutation:\r\n\r\n```\r\nClient Request\r\n      │\r\n      ▼\r\n┌─────────────┐    ┌──────────────┐\r\n│ verify(key) │───▶│ SHA-256 Hash │\r\n└─────────────┘    └──────┬───────┘\r\n                          ▼\r\n                   ┌──────────────┐\r\n                   │  Lookup Key  │\r\n                   │  (by hash)   │\r\n                   └──────┬───────┘\r\n                          ▼\r\n              ┌───────────────────────┐\r\n              │   Validation Chain    │\r\n              │  1. revoked?          │\r\n              │  2. disabled?         │\r\n              │  3. expired?          │\r\n              │  4. rotation grace?   │\r\n              │  5. refill credits?   │\r\n              │  6. credits left?     │\r\n              │  7. per-key rate ok?  │\r\n              │  8. owner rate ok?    │\r\n              └───────────┬───────────┘\r\n                          ▼\r\n              ┌───────────────────────┐\r\n              │  Resolve Permissions  │\r\n              │  direct ∪ role perms  │\r\n              └───────────┬───────────┘\r\n                          ▼\r\n              ┌───────────────────────┐\r\n              │  Decrement Credits    │\r\n              │  Log Verification     │\r\n              │  Return Result        │\r\n              └───────────────────────┘\r\n```\r\n\r\nIf any check fails, the chain short-circuits with the appropriate `code` (e.g., `RATE_LIMITED`, `USAGE_EXCEEDED`). Credits are only decremented on successful verification.\r\n\r\n### Rate Limiting Flow\r\n\r\n```\r\nverify() called\r\n      │\r\n      ▼\r\n┌─────────────────────────────────┐\r\n│  Per-Key Rate Limit             │\r\n│  ┌────────────────────────┐     │\r\n│  │ 1. Check override for  │     │\r\n│  │    this key's hash     │     │     rateLimitOverrides\r\n│  │ 2. Fall back to key's  │◀────┼──── (by keyOrOwnerId)\r\n│  │    default ratelimit   │     │\r\n│  │ 3. checkAndUpdate      │     │     rateLimitBuckets\r\n│  │    RateLimit(hash)     │────▶┼──── (sliding window)\r\n│  └────────────────────────┘     │\r\n│           │ pass                │\r\n│           ▼                     │\r\n│  Per-Owner Shared Rate Limit    │\r\n│  ┌────────────────────────┐     │\r\n│  │ 1. Check override for  │     │     rateLimitOverrides\r\n│  │    this key's ownerId  │◀────┼──── (by keyOrOwnerId)\r\n│  │ 2. If found, checkAnd  │     │\r\n│  │    UpdateRateLimit     │     │     rateLimitBuckets\r\n│  │    (ownerId)           │────▶┼──── (shared bucket)\r\n│  └────────────────────────┘     │\r\n│           │ pass                │\r\n│           ▼                     │\r\n│  Continue verification...       │\r\n└─────────────────────────────────┘\r\n```\r\n\r\n### Analytics Rollup Pipeline\r\n\r\n```\r\nConvex Scheduler\r\n      │\r\n      ├── Every 1 hour: rollupAnalytics\r\n      │     │\r\n      │     ├── Query verificationLogs WHERE timestamp ∈ [prevHourStart, currHourStart)\r\n      │     ├── Group by keyHash → tally outcome codes\r\n      │     └── Upsert into analyticsRollups (period=\"hour\")\r\n      │\r\n      ├── Every 24 hours: rollupDaily\r\n      │     │\r\n      │     ├── Query analyticsRollups WHERE period=\"hour\" AND timestamp ∈ [prevDayStart, currDayStart)\r\n      │     ├── Group by keyHash → sum hourly buckets\r\n      │     └── Upsert into analyticsRollups (period=\"day\")\r\n      │\r\n      └── Every 24 hours: cleanupLogs\r\n            │\r\n            └── Delete verificationLogs WHERE timestamp < (now - 90 days)\r\n\r\nDashboard queries read from:\r\n  ┌─────────────────────┐    ┌──────────────────────┐\r\n  │ getUsageStats       │───▶│ analyticsRollups     │ (when period specified)\r\n  │ getUsageByOwner     │    │ (pre-aggregated)     │\r\n  │ getOverallStats     │    └──────────────────────┘\r\n  │ getTopKeysByUsage   │\r\n  │ getVerifications    │    ┌──────────────────────┐\r\n  │   OverTime          │───▶│ verificationLogs     │ (raw, when no period)\r\n  └─────────────────────┘    │ (recent data)        │\r\n                             └──────────────────────┘\r\n```\r\n\r\n### Data Model\r\n\r\n```\r\n┌──────────┐     ┌────────────┐     ┌─────────────────┐\r\n│   keys   │────▶│   roles    │────▶│   permissions   │\r\n│          │     │ (roleIds)  │     │ (permissionIds) │\r\n│ hash     │     └────────────┘     └─────────────────┘\r\n│ ownerId  │\r\n│ namespace│     ┌──────────────────┐\r\n│ ratelimit│────▶│rateLimitOverrides│\r\n│ refill   │     │ per-key or       │\r\n│ remaining│     │ per-owner limits │\r\n└────┬─────┘     └──────────────────┘\r\n     │\r\n     │           ┌──────────────────┐\r\n     └──────────▶│ verificationLogs │\r\n                 │ (append-only)    │\r\n                 └────────┬─────────┘\r\n                          │  rollup\r\n                          ▼\r\n                 ┌──────────────────┐\r\n                 │analyticsRollups  │\r\n                 │ hourly + daily   │\r\n                 └──────────────────┘\r\n\r\n┌──────────────────┐\r\n│ rateLimitBuckets │  (sliding window state)\r\n└──────────────────┘\r\n\r\n┌──────────┐\r\n│ auditLog │  (all mutations logged)\r\n└──────────┘\r\n```\r\n\r\n**8 tables, all private to the component:**\r\n\r\n| Table                  | Purpose                                   | Indexes                                                     |\r\n| :--------------------- | :---------------------------------------- | :---------------------------------------------------------- |\r\n| `keys`                 | API key records (hashed, never plaintext) | `by_hash`, `by_owner`, `by_namespace`, `by_expires`, `by_unkey_id` |\r\n| `rateLimitBuckets`     | Sliding window counters per key or owner  | `by_key_namespace`                                          |\r\n| `verificationLogs`     | Every `verify()` attempt with outcome     | `by_key_time`, `by_time`                                    |\r\n| `analyticsRollups`     | Pre-aggregated hourly and daily stats     | `by_ns_period`, `by_key_period`                             |\r\n| `permissions`          | Named permission entities                 | `by_name`                                                   |\r\n| `roles`                | Named roles bundling permissions          | `by_name`                                                   |\r\n| `rateLimitOverrides`   | Per-key and per-owner rate limit overrides | `by_key_namespace`                                         |\r\n| `auditLog`             | All mutation operations with details      | `by_time`, `by_key`, `by_actor`                             |\r\n\r\n\r\n### RBAC Model\r\n\r\n```\r\n┌─────────┐   assignRoles   ┌───────────┐   permissions[]   ┌──────────────┐\r\n│   Key   │─────────────────▶│   Role    │──────────────────▶│  Permission  │\r\n│         │                  │ \"admin\"   │                   │ \"keys:write\" │\r\n│         │  assignPerms     │ \"viewer\"  │                   │ \"keys:read\"  │\r\n│         │─────────────────▶└───────────┘                   └──────────────┘\r\n│         │  (direct perms)          ▲\r\n└─────────┘                          │\r\n                              createRole(name,\r\n                                permissions[])\r\n\r\nverify() → resolvePermissions():\r\n  directPerms ∪ (roles → flatMap(role.permissions))\r\n```\r\n\r\nKeys can receive permissions two ways:\r\n1. **Direct assignment** via `assignPermissions` -- useful for one-off grants\r\n2. **Role inheritance** via `assignRoles` -- each role bundles multiple permissions\r\n\r\nOn `verify()`, the component resolves both paths and returns the merged set.\r\n\r\n---\r\n\r\n## Verification Outcome Codes\r\n\r\n| Code                       | Meaning                                        | Triggered when                        |\r\n| :------------------------- | :--------------------------------------------- | :------------------------------------ |\r\n| `VALID`                    | Key is valid and authorized                    | All checks pass                       |\r\n| `NOT_FOUND`                | No key matches the provided value              | Hash not in database                  |\r\n| `REVOKED`                  | Key has been revoked                           | `revokedAt` is set                    |\r\n| `DISABLED`                 | Key is temporarily disabled                    | `enabled === false`                   |\r\n| `EXPIRED`                  | Key has passed its expiration                  | `expires < now`                       |\r\n| `ROTATION_GRACE_EXPIRED`   | Old key's grace period ended after rotation    | `rotationGraceEnd < now`              |\r\n| `USAGE_EXCEEDED`           | Usage credits exhausted                        | `remaining <= 0`                      |\r\n| `RATE_LIMITED`             | Too many requests in the current window        | Per-key or per-owner limit exceeded   |\r\n\r\n---\r\n\r\n## Configuration\r\n\r\n```ts\r\nconst apiKeys = new ApiKeys(components.apiKeys, {\r\n  defaultNamespace: \"production\",\r\n  defaultPrefix: \"sk_live_\",\r\n  keyBytes: 32,\r\n  logRetentionDays: 90,\r\n});\r\n```\r\n\r\n| Option              | Type     | Default     | Description                                                     |\r\n| :------------------ | :------- | :---------- | :-------------------------------------------------------------- |\r\n| `defaultNamespace`  | `string` | `undefined` | Namespace auto-applied to create/verify/listKeys when not specified |\r\n| `defaultPrefix`     | `string` | `undefined` | Key prefix auto-applied to create when not specified            |\r\n| `keyBytes`          | `number` | `32`        | Random bytes of entropy for key generation                      |\r\n| `logRetentionDays`  | `number` | `90`        | Used by `purgeVerificationLogs` to compute the cutoff timestamp |\r\n| `rollupInterval`    | `number` | `3600000`   | Rollup interval in ms (informational; cron runs hourly)         |\r\n\r\n---\r\n\r\n## Scheduled Jobs (Crons)\r\n\r\nThe component registers these internal cron jobs automatically:\r\n\r\n| Job                 | Interval  | Description                                                  |\r\n| :------------------ | :-------- | :----------------------------------------------------------- |\r\n| `expire keys`       | 1 hour    | Disables keys past their `expires` timestamp                 |\r\n| `rollup analytics`  | 1 hour    | Aggregates verification logs into hourly `analyticsRollups`  |\r\n| `rollup daily`      | 24 hours  | Aggregates hourly rollups into daily `analyticsRollups`      |\r\n| `cleanup logs`      | 24 hours  | Deletes verification logs older than 90 days                 |\r\n\r\n---\r\n\r\n## Security Considerations\r\n\r\n- **Keys are hashed** with SHA-256 before storage. The plaintext key is only returned once on `create` / `rotate` and never persists.\r\n- **Constant-time-ish lookup**: keys are looked up by hash index, not compared character by character. Timing attacks on the hash lookup are mitigated by Convex's query planner.\r\n- **Component isolation**: all tables are private. Your app code cannot directly read the `keys` table -- access is strictly through the component's mutation/query API.\r\n- **Audit trail**: every `create`, `revoke`, `update`, `rotate`, `assignRoles`, `assignPermissions`, `purge`, and `setRateLimitOverride` is logged to the `auditLog` table with timestamps and details.\r\n\r\n---\r\n\r\n## Exported Types\r\n\r\n```ts\r\nimport type {\r\n  VerificationResult,   // verify() return type\r\n  CreateKeyResult,      // create() return type\r\n  KeyInfo,              // getKey() / listKeys() item shape\r\n  UsageStats,           // getUsageStats() return type\r\n  OverallStats,         // getOverallStats() return type\r\n  AuditEntry,           // getAuditLog() item shape\r\n  VerificationEntry,    // getVerificationLog() item shape\r\n  OutcomeCode,          // \"VALID\" | \"REVOKED\" | \"EXPIRED\" | ...\r\n  ApiKeysConfig,        // Constructor config type\r\n  RunMutationCtx,       // Context type for mutation methods\r\n  RunQueryCtx,          // Context type for query methods\r\n  RunActionCtx,         // Context type for action methods (Unkey mode)\r\n} from \"@00akshatsinha00/convex-api-keys\";\r\n\r\n// Unkey integration types (optional)\r\nimport type { UnkeyApiKeys, UnkeyConfig } from \"@00akshatsinha00/convex-api-keys/unkey\";\r\n```\r\n\r\nSee more example usage in [example.ts](./example/convex/example.ts).\r\n\r\n---\r\n\r\n## Development\r\n\r\n```sh\r\nnpm i\r\nnpm run build\r\nnpm test\r\n```\r\n\r\n## License\r\n\r\nApache-2.0\r\n","readmeFilename":"README.md","_rev":"1-3ae9c0fbe327d39cac740dde723401da"}