@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 +13 -0
- package/api/index.ts +12 -5
- package/migrations/001_initial.ts +151 -0
- package/migrations/002_polar_billing.ts +17 -0
- package/migrations/003_verification_table.ts +18 -0
- package/migrations/004_rename_tables_singular.ts +14 -0
- package/package.json +14 -3
- package/src/auth/config.ts +53 -0
- package/src/auth/hooks.ts +45 -0
- package/src/db/client.ts +15 -0
- package/src/db/migrate.ts +38 -0
- package/src/index.ts +35 -5
- package/src/middleware/api-key-auth.ts +94 -0
- package/src/middleware/org-rate-limit.ts +78 -0
- package/src/routes/api-keys.ts +127 -0
- package/src/routes/auth.ts +62 -0
- package/src/routes/billing.ts +202 -0
- package/src/routes/coverage.ts +270 -0
- package/src/routes/orgs.ts +138 -0
- package/src/routes/plan.ts +2 -72
- package/src/server.ts +22 -0
- package/src/utils/api-keys.ts +20 -0
- package/vercel.json +1 -3
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(
|
|
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(
|
|
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
|
+
"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/
|
|
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/
|
|
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
|
+
}
|
package/src/db/client.ts
ADDED
|
@@ -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('*',
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|