@doccov/api 0.3.6 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @doccov/api
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat: add platform auth, billing, org management & coverage tracking
8
+
9
+ ## 0.3.7
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies
14
+ - @doccov/sdk@0.15.0
15
+
3
16
  ## 0.3.6
4
17
 
5
18
  ### Patch Changes
package/api/index.ts CHANGED
@@ -183,7 +183,6 @@ function wrapLocalBinary(
183
183
  return { cmd: 'yarn', args: [cmd, ...args] }; // yarn runs local bins directly
184
184
  case 'bun':
185
185
  return { cmd: 'bunx', args: [cmd, ...args] };
186
- case 'npm':
187
186
  default:
188
187
  return { cmd: 'npx', args: [cmd, ...args] };
189
188
  }
@@ -670,7 +669,11 @@ async function handleExecute(req: VercelRequest, res: VercelResponse): Promise<v
670
669
 
671
670
  try {
672
671
  const normalizedCwd = normalizeCwd(step.cwd);
673
- const { cmd, args } = wrapLocalBinary(step.command, step.args, plan.environment.packageManager);
672
+ const { cmd, args } = wrapLocalBinary(
673
+ step.command,
674
+ step.args,
675
+ plan.environment.packageManager,
676
+ );
674
677
  const result = await sandbox.runCommand({
675
678
  cmd,
676
679
  args,
@@ -711,7 +714,7 @@ async function handleExecute(req: VercelRequest, res: VercelResponse): Promise<v
711
714
  const analyzeCwd = normalizeCwd(plan.target.rootPath);
712
715
 
713
716
  // If running in a subdirectory (rootPath), strip the rootPath prefix from entryPoint
714
- if (plan.target.rootPath && entryPoint.startsWith(plan.target.rootPath + '/')) {
717
+ if (plan.target.rootPath && entryPoint.startsWith(`${plan.target.rootPath}/`)) {
715
718
  entryPoint = entryPoint.slice(plan.target.rootPath.length + 1);
716
719
  }
717
720
 
@@ -837,7 +840,11 @@ async function handleExecuteStream(req: VercelRequest, res: VercelResponse): Pro
837
840
 
838
841
  try {
839
842
  const normalizedCwd = normalizeCwd(step.cwd);
840
- const { cmd, args } = wrapLocalBinary(step.command, step.args, plan.environment.packageManager);
843
+ const { cmd, args } = wrapLocalBinary(
844
+ step.command,
845
+ step.args,
846
+ plan.environment.packageManager,
847
+ );
841
848
  const result = await sandbox.runCommand({
842
849
  cmd,
843
850
  args,
@@ -891,7 +898,7 @@ async function handleExecuteStream(req: VercelRequest, res: VercelResponse): Pro
891
898
 
892
899
  // If running in a subdirectory (rootPath), strip the rootPath prefix from entryPoint
893
900
  // e.g., rootPath="packages/v0-sdk", entryPoint="packages/v0-sdk/src/index.ts" -> "src/index.ts"
894
- if (plan.target.rootPath && entryPoint.startsWith(plan.target.rootPath + '/')) {
901
+ if (plan.target.rootPath && entryPoint.startsWith(`${plan.target.rootPath}/`)) {
895
902
  entryPoint = entryPoint.slice(plan.target.rootPath.length + 1);
896
903
  }
897
904
 
@@ -0,0 +1,151 @@
1
+ import type { Kysely } from 'kysely';
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ // Users (Better Auth)
5
+ await db.schema
6
+ .createTable('users')
7
+ .addColumn('id', 'text', (col) => col.primaryKey())
8
+ .addColumn('email', 'text', (col) => col.notNull().unique())
9
+ .addColumn('email_verified', 'boolean', (col) => col.defaultTo(false))
10
+ .addColumn('name', 'text')
11
+ .addColumn('image', 'text')
12
+ .addColumn('github_id', 'text', (col) => col.unique())
13
+ .addColumn('github_username', 'text')
14
+ .addColumn('plan', 'text', (col) => col.defaultTo('free'))
15
+ .addColumn('stripe_customer_id', 'text')
16
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
17
+ .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
18
+ .execute();
19
+
20
+ // Sessions (Better Auth)
21
+ await db.schema
22
+ .createTable('sessions')
23
+ .addColumn('id', 'text', (col) => col.primaryKey())
24
+ .addColumn('user_id', 'text', (col) => col.notNull().references('users.id').onDelete('cascade'))
25
+ .addColumn('token', 'text', (col) => col.notNull().unique())
26
+ .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
27
+ .addColumn('ip_address', 'text')
28
+ .addColumn('user_agent', 'text')
29
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
30
+ .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
31
+ .execute();
32
+
33
+ // Accounts (Better Auth OAuth)
34
+ await db.schema
35
+ .createTable('accounts')
36
+ .addColumn('id', 'text', (col) => col.primaryKey())
37
+ .addColumn('user_id', 'text', (col) => col.notNull().references('users.id').onDelete('cascade'))
38
+ .addColumn('account_id', 'text', (col) => col.notNull())
39
+ .addColumn('provider_id', 'text', (col) => col.notNull())
40
+ .addColumn('access_token', 'text')
41
+ .addColumn('refresh_token', 'text')
42
+ .addColumn('access_token_expires_at', 'timestamptz')
43
+ .addColumn('refresh_token_expires_at', 'timestamptz')
44
+ .addColumn('scope', 'text')
45
+ .addColumn('id_token', 'text')
46
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
47
+ .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
48
+ .execute();
49
+
50
+ // Organizations
51
+ await db.schema
52
+ .createTable('organizations')
53
+ .addColumn('id', 'text', (col) => col.primaryKey())
54
+ .addColumn('name', 'text', (col) => col.notNull())
55
+ .addColumn('slug', 'text', (col) => col.notNull().unique())
56
+ .addColumn('is_personal', 'boolean', (col) => col.defaultTo(false))
57
+ .addColumn('github_org', 'text')
58
+ .addColumn('github_installation_id', 'text')
59
+ .addColumn('plan', 'text', (col) => col.defaultTo('free'))
60
+ .addColumn('stripe_subscription_id', 'text')
61
+ .addColumn('ai_calls_used', 'integer', (col) => col.defaultTo(0))
62
+ .addColumn('ai_calls_reset_at', 'timestamptz')
63
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
64
+ .execute();
65
+
66
+ // Org Members
67
+ await db.schema
68
+ .createTable('org_members')
69
+ .addColumn('id', 'text', (col) => col.primaryKey())
70
+ .addColumn('org_id', 'text', (col) =>
71
+ col.notNull().references('organizations.id').onDelete('cascade'),
72
+ )
73
+ .addColumn('user_id', 'text', (col) => col.notNull().references('users.id').onDelete('cascade'))
74
+ .addColumn('role', 'text', (col) => col.defaultTo('member'))
75
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
76
+ .execute();
77
+
78
+ // API Keys
79
+ await db.schema
80
+ .createTable('api_keys')
81
+ .addColumn('id', 'text', (col) => col.primaryKey())
82
+ .addColumn('org_id', 'text', (col) =>
83
+ col.notNull().references('organizations.id').onDelete('cascade'),
84
+ )
85
+ .addColumn('name', 'text', (col) => col.notNull())
86
+ .addColumn('key_hash', 'text', (col) => col.notNull())
87
+ .addColumn('key_prefix', 'text', (col) => col.notNull())
88
+ .addColumn('scopes', 'text')
89
+ .addColumn('last_used_at', 'timestamptz')
90
+ .addColumn('expires_at', 'timestamptz')
91
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
92
+ .execute();
93
+
94
+ // Projects
95
+ await db.schema
96
+ .createTable('projects')
97
+ .addColumn('id', 'text', (col) => col.primaryKey())
98
+ .addColumn('org_id', 'text', (col) =>
99
+ col.notNull().references('organizations.id').onDelete('cascade'),
100
+ )
101
+ .addColumn('name', 'text', (col) => col.notNull())
102
+ .addColumn('full_name', 'text', (col) => col.notNull())
103
+ .addColumn('is_private', 'boolean', (col) => col.defaultTo(false))
104
+ .addColumn('default_branch', 'text', (col) => col.defaultTo('main'))
105
+ .addColumn('coverage_score', 'integer')
106
+ .addColumn('drift_count', 'integer')
107
+ .addColumn('last_analyzed_at', 'timestamptz')
108
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
109
+ .execute();
110
+
111
+ // Usage Records
112
+ await db.schema
113
+ .createTable('usage_records')
114
+ .addColumn('id', 'text', (col) => col.primaryKey())
115
+ .addColumn('org_id', 'text', (col) =>
116
+ col.notNull().references('organizations.id').onDelete('cascade'),
117
+ )
118
+ .addColumn('feature', 'text', (col) => col.notNull())
119
+ .addColumn('count', 'integer', (col) => col.defaultTo(1))
120
+ .addColumn('metadata', 'text')
121
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
122
+ .execute();
123
+
124
+ // Indexes
125
+ await db.schema.createIndex('idx_sessions_user_id').on('sessions').column('user_id').execute();
126
+ await db.schema.createIndex('idx_sessions_token').on('sessions').column('token').execute();
127
+ await db.schema.createIndex('idx_accounts_user_id').on('accounts').column('user_id').execute();
128
+ await db.schema
129
+ .createIndex('idx_org_members_org_id')
130
+ .on('org_members')
131
+ .column('org_id')
132
+ .execute();
133
+ await db.schema
134
+ .createIndex('idx_org_members_user_id')
135
+ .on('org_members')
136
+ .column('user_id')
137
+ .execute();
138
+ await db.schema.createIndex('idx_projects_org_id').on('projects').column('org_id').execute();
139
+ await db.schema.createIndex('idx_api_keys_org_id').on('api_keys').column('org_id').execute();
140
+ }
141
+
142
+ export async function down(db: Kysely<unknown>): Promise<void> {
143
+ await db.schema.dropTable('usage_records').execute();
144
+ await db.schema.dropTable('projects').execute();
145
+ await db.schema.dropTable('api_keys').execute();
146
+ await db.schema.dropTable('org_members').execute();
147
+ await db.schema.dropTable('organizations').execute();
148
+ await db.schema.dropTable('accounts').execute();
149
+ await db.schema.dropTable('sessions').execute();
150
+ await db.schema.dropTable('users').execute();
151
+ }
@@ -0,0 +1,17 @@
1
+ import type { Kysely } from 'kysely';
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ await db.schema
5
+ .alterTable('organizations')
6
+ .addColumn('polar_customer_id', 'text')
7
+ .addColumn('polar_subscription_id', 'text')
8
+ .execute();
9
+ }
10
+
11
+ export async function down(db: Kysely<unknown>): Promise<void> {
12
+ await db.schema
13
+ .alterTable('organizations')
14
+ .dropColumn('polar_customer_id')
15
+ .dropColumn('polar_subscription_id')
16
+ .execute();
17
+ }
@@ -0,0 +1,18 @@
1
+ import type { Kysely } from 'kysely';
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ // Verification table required by better-auth for OAuth state
5
+ await db.schema
6
+ .createTable('verification')
7
+ .addColumn('id', 'text', (col) => col.primaryKey())
8
+ .addColumn('identifier', 'text', (col) => col.notNull())
9
+ .addColumn('value', 'text', (col) => col.notNull())
10
+ .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
11
+ .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
12
+ .addColumn('updated_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
13
+ .execute();
14
+ }
15
+
16
+ export async function down(db: Kysely<unknown>): Promise<void> {
17
+ await db.schema.dropTable('verification').execute();
18
+ }
@@ -0,0 +1,14 @@
1
+ import type { Kysely } from 'kysely';
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ // Better-auth expects singular table names
5
+ await db.schema.alterTable('users').renameTo('user').execute();
6
+ await db.schema.alterTable('sessions').renameTo('session').execute();
7
+ await db.schema.alterTable('accounts').renameTo('account').execute();
8
+ }
9
+
10
+ export async function down(db: Kysely<unknown>): Promise<void> {
11
+ await db.schema.alterTable('user').renameTo('users').execute();
12
+ await db.schema.alterTable('session').renameTo('sessions').execute();
13
+ await db.schema.alterTable('account').renameTo('accounts').execute();
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/api",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "DocCov API - Badge endpoint and coverage services",
5
5
  "keywords": [
6
6
  "doccov",
@@ -19,25 +19,36 @@
19
19
  "author": "Ryan Waits",
20
20
  "type": "module",
21
21
  "scripts": {
22
- "dev": "bun run --hot src/index.ts",
22
+ "dev": "bun run --hot src/server.ts",
23
23
  "start": "bun run src/index.ts",
24
+ "server": "bun run src/server.ts",
25
+ "migrate": "bun run src/db/migrate.ts",
24
26
  "lint": "biome check src/ api/",
25
27
  "lint:fix": "biome check --write src/ api/",
26
28
  "format": "biome format --write src/ api/"
27
29
  },
28
30
  "dependencies": {
29
31
  "@ai-sdk/anthropic": "^2.0.55",
30
- "@doccov/sdk": "^0.13.0",
32
+ "@doccov/db": "workspace:*",
33
+ "@doccov/sdk": "^0.15.0",
34
+ "@hono/node-server": "^1.14.3",
31
35
  "@openpkg-ts/spec": "^0.9.0",
36
+ "@polar-sh/hono": "^0.5.3",
32
37
  "@vercel/sandbox": "^1.0.3",
33
38
  "ai": "^5.0.111",
39
+ "better-auth": "^1.2.8",
40
+ "hono": "^4.7.10",
41
+ "kysely": "^0.27.4",
34
42
  "ms": "^2.1.3",
43
+ "nanoid": "^5.0.0",
44
+ "pg": "^8.13.0",
35
45
  "zod": "^3.25.0"
36
46
  },
37
47
  "devDependencies": {
38
48
  "@types/bun": "latest",
39
49
  "@types/ms": "^0.7.34",
40
50
  "@types/node": "^20.0.0",
51
+ "@types/pg": "^8.11.0",
41
52
  "@vercel/node": "^3.0.0",
42
53
  "typescript": "^5.0.0"
43
54
  }
@@ -0,0 +1,53 @@
1
+ import { betterAuth } from 'better-auth';
2
+ import { db } from '../db/client';
3
+ import { createPersonalOrg } from './hooks';
4
+
5
+ export const auth = betterAuth({
6
+ basePath: '/auth',
7
+
8
+ database: {
9
+ db,
10
+ type: 'postgres',
11
+ },
12
+
13
+ emailAndPassword: {
14
+ enabled: false,
15
+ },
16
+
17
+ socialProviders: {
18
+ github: {
19
+ clientId: process.env.GITHUB_CLIENT_ID!,
20
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
21
+ scope: ['user:email', 'read:org'],
22
+ },
23
+ },
24
+
25
+ session: {
26
+ expiresIn: 60 * 60 * 24 * 30, // 30 days
27
+ updateAge: 60 * 60 * 24, // 24 hours
28
+ },
29
+
30
+ user: {
31
+ additionalFields: {
32
+ githubId: { type: 'string', required: false },
33
+ githubUsername: { type: 'string', required: false },
34
+ plan: { type: 'string', defaultValue: 'free' },
35
+ stripeCustomerId: { type: 'string', required: false },
36
+ },
37
+ },
38
+
39
+ databaseHooks: {
40
+ user: {
41
+ create: {
42
+ after: async (user) => {
43
+ // Create personal org for new users
44
+ await createPersonalOrg(user.id, user.name, user.email);
45
+ },
46
+ },
47
+ },
48
+ },
49
+
50
+ trustedOrigins: [process.env.SITE_URL || 'http://localhost:3000'],
51
+ });
52
+
53
+ export type Auth = typeof auth;
@@ -0,0 +1,45 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { db } from '../db/client';
3
+
4
+ export async function createPersonalOrg(userId: string, userName: string | null, email: string) {
5
+ const orgId = nanoid(21);
6
+ const baseName = userName || email.split('@')[0];
7
+ const slug = baseName
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, '-')
10
+ .replace(/^-|-$/g, '')
11
+ .slice(0, 32);
12
+
13
+ // Ensure unique slug
14
+ const existing = await db
15
+ .selectFrom('organizations')
16
+ .where('slug', '=', slug)
17
+ .select('id')
18
+ .executeTakeFirst();
19
+
20
+ const finalSlug = existing ? `${slug}-${nanoid(6)}` : slug;
21
+
22
+ await db
23
+ .insertInto('organizations')
24
+ .values({
25
+ id: orgId,
26
+ name: baseName,
27
+ slug: finalSlug,
28
+ isPersonal: true,
29
+ plan: 'free',
30
+ aiCallsUsed: 0,
31
+ })
32
+ .execute();
33
+
34
+ await db
35
+ .insertInto('org_members')
36
+ .values({
37
+ id: nanoid(21),
38
+ orgId: orgId,
39
+ userId: userId,
40
+ role: 'owner',
41
+ })
42
+ .execute();
43
+
44
+ return orgId;
45
+ }
@@ -0,0 +1,15 @@
1
+ import type { Database } from '@doccov/db';
2
+ import { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely';
3
+ import { Pool } from 'pg';
4
+
5
+ const pool = new Pool({
6
+ connectionString: process.env.DATABASE_URL,
7
+ max: 10,
8
+ });
9
+
10
+ export const db = new Kysely<Database>({
11
+ dialect: new PostgresDialect({ pool }),
12
+ plugins: [new CamelCasePlugin()],
13
+ });
14
+
15
+ export type DB = typeof db;
@@ -0,0 +1,38 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { FileMigrationProvider, Migrator } from 'kysely';
4
+ import { db } from './client';
5
+
6
+ const migrator = new Migrator({
7
+ db,
8
+ provider: new FileMigrationProvider({
9
+ fs,
10
+ path,
11
+ migrationFolder: path.join(import.meta.dirname, '../../migrations'),
12
+ }),
13
+ });
14
+
15
+ export async function runMigrations() {
16
+ const { error, results } = await migrator.migrateToLatest();
17
+
18
+ results?.forEach((it) => {
19
+ if (it.status === 'Success') {
20
+ console.log(`Migration "${it.migrationName}" executed successfully`);
21
+ } else if (it.status === 'Error') {
22
+ console.error(`Migration "${it.migrationName}" failed`);
23
+ }
24
+ });
25
+
26
+ if (error) {
27
+ console.error('Migration failed:', error);
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ // CLI entry point
33
+ if (import.meta.main) {
34
+ runMigrations().then(() => {
35
+ console.log('Migrations complete');
36
+ process.exit(0);
37
+ });
38
+ }
package/src/index.ts CHANGED
@@ -1,13 +1,28 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
+ import { logger } from 'hono/logger';
4
+ import { requireApiKey } from './middleware/api-key-auth';
5
+ import { orgRateLimit } from './middleware/org-rate-limit';
3
6
  import { rateLimit } from './middleware/rate-limit';
7
+ import { apiKeysRoute } from './routes/api-keys';
8
+ import { authRoute } from './routes/auth';
4
9
  import { badgeRoute } from './routes/badge';
10
+ import { billingRoute } from './routes/billing';
11
+ import { coverageRoute } from './routes/coverage';
12
+ import { orgsRoute } from './routes/orgs';
5
13
  import { planRoute } from './routes/plan';
6
14
 
7
15
  const app = new Hono();
8
16
 
9
17
  // Middleware
10
- app.use('*', cors());
18
+ app.use('*', logger());
19
+ app.use(
20
+ '*',
21
+ cors({
22
+ origin: process.env.SITE_URL || 'http://localhost:3000',
23
+ credentials: true,
24
+ }),
25
+ );
11
26
 
12
27
  // Rate limit /plan endpoint: 10 requests per minute per IP
13
28
  app.use(
@@ -23,12 +38,16 @@ app.use(
23
38
  app.get('/', (c) => {
24
39
  return c.json({
25
40
  name: 'DocCov API',
26
- version: '0.4.0',
41
+ version: '0.5.0',
27
42
  endpoints: {
43
+ auth: '/auth/*',
44
+ apiKeys: '/api-keys/*',
28
45
  badge: '/badge/:owner/:repo',
46
+ billing: '/billing/*',
47
+ coverage: '/coverage/*',
48
+ orgs: '/orgs/*',
29
49
  plan: '/plan',
30
- execute: '/execute',
31
- 'execute-stream': '/execute-stream',
50
+ v1: '/v1/* (API key required)',
32
51
  health: '/health',
33
52
  },
34
53
  });
@@ -38,9 +57,20 @@ app.get('/health', (c) => {
38
57
  return c.json({ status: 'ok', timestamp: new Date().toISOString() });
39
58
  });
40
59
 
41
- // Routes
60
+ // Public endpoints (no auth)
42
61
  app.route('/badge', badgeRoute);
62
+
63
+ // Dashboard endpoints (session auth)
64
+ app.route('/auth', authRoute);
65
+ app.route('/api-keys', apiKeysRoute);
66
+ app.route('/billing', billingRoute);
67
+ app.route('/coverage', coverageRoute);
68
+ app.route('/orgs', orgsRoute);
43
69
  app.route('/plan', planRoute);
44
70
 
71
+ // API endpoints (API key required)
72
+ app.use('/v1/*', requireApiKey(), orgRateLimit());
73
+ // TODO: app.route('/v1/analyze', analyzeRoute);
74
+
45
75
  // Vercel serverless handler + Bun auto-serves this export
46
76
  export default app;
@@ -0,0 +1,94 @@
1
+ import type { Context, MiddlewareHandler, Next } from 'hono';
2
+ import { db } from '../db/client';
3
+ import { hashApiKey, isValidKeyFormat } from '../utils/api-keys';
4
+
5
+ export interface ApiKeyContext {
6
+ apiKey: { id: string; orgId: string; name: string };
7
+ org: { id: string; plan: string; aiCallsUsed: number; aiCallsResetAt: Date | null };
8
+ }
9
+
10
+ /**
11
+ * Required API key authentication for /v1/* endpoints
12
+ * Rejects requests without valid API key
13
+ */
14
+ export function requireApiKey(): MiddlewareHandler {
15
+ return async (c: Context, next: Next) => {
16
+ const authHeader = c.req.header('Authorization');
17
+
18
+ if (!authHeader) {
19
+ return c.json(
20
+ {
21
+ error: 'API key required',
22
+ docs: 'https://docs.doccov.com/api-keys',
23
+ },
24
+ 401,
25
+ );
26
+ }
27
+
28
+ if (!authHeader.startsWith('Bearer ')) {
29
+ return c.json({ error: 'Invalid Authorization header. Use: Bearer <api_key>' }, 401);
30
+ }
31
+
32
+ const key = authHeader.slice(7);
33
+
34
+ if (!isValidKeyFormat(key)) {
35
+ return c.json({ error: 'Invalid API key format' }, 401);
36
+ }
37
+
38
+ const keyHash = hashApiKey(key);
39
+
40
+ const result = await db
41
+ .selectFrom('api_keys')
42
+ .innerJoin('organizations', 'organizations.id', 'api_keys.orgId')
43
+ .where('api_keys.keyHash', '=', keyHash)
44
+ .where((eb) =>
45
+ eb.or([eb('api_keys.expiresAt', 'is', null), eb('api_keys.expiresAt', '>', new Date())]),
46
+ )
47
+ .select([
48
+ 'api_keys.id as keyId',
49
+ 'api_keys.orgId',
50
+ 'api_keys.name as keyName',
51
+ 'organizations.plan',
52
+ 'organizations.aiCallsUsed',
53
+ 'organizations.aiCallsResetAt',
54
+ ])
55
+ .executeTakeFirst();
56
+
57
+ if (!result) {
58
+ return c.json({ error: 'Invalid or expired API key' }, 401);
59
+ }
60
+
61
+ // Free tier shouldn't have API keys, but guard anyway
62
+ if (result.plan === 'free') {
63
+ return c.json(
64
+ {
65
+ error: 'API access requires a paid plan',
66
+ upgrade: 'https://doccov.com/pricing',
67
+ },
68
+ 403,
69
+ );
70
+ }
71
+
72
+ // Update last used (async, don't block)
73
+ db.updateTable('api_keys')
74
+ .set({ lastUsedAt: new Date() })
75
+ .where('id', '=', result.keyId)
76
+ .execute()
77
+ .catch(console.error);
78
+
79
+ c.set('apiKey', {
80
+ id: result.keyId,
81
+ orgId: result.orgId,
82
+ name: result.keyName,
83
+ });
84
+
85
+ c.set('org', {
86
+ id: result.orgId,
87
+ plan: result.plan,
88
+ aiCallsUsed: result.aiCallsUsed,
89
+ aiCallsResetAt: result.aiCallsResetAt,
90
+ });
91
+
92
+ await next();
93
+ };
94
+ }